From b06bd8e8b0fba6a6571c573535f2ddde4e88113b Mon Sep 17 00:00:00 2001 From: BurntToasters <61037367+BurntToasters@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:16:36 -0700 Subject: [PATCH 1/3] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bb569..50b35aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ | Windows | MacOS | Linux | | :--- | :--- | :--- | -| **MSI (Recommended): [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Windows-x64.msi)**| **[Universal DMG](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-macOS.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.AppImage) | +| **MSI: [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Windows-x64.msi)**| **[Universal DMG](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-macOS.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.AppImage) | | | **[Universal ZIP](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-macOS.zip)** | **DEB:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-amd64.deb) | | | | **RPM:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.rpm) | | | | **Flatpak:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.flatpak) | From 68e14946b6501677ec15af795789b6f4fa74b10d Mon Sep 17 00:00:00 2001 From: BurntToasters <61037367+BurntToasters@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:39:16 -0700 Subject: [PATCH 2/3] v bjump --- CHANGELOG.md | 16 +++++++++------- flatpak/run.rosie.dacx.yaml | 2 +- linux/packaging/control.template | 2 +- package-lock.json | 4 ++-- package.json | 2 +- pubspec.yaml | 2 +- run.rosie.dacx.metainfo.xml | 1 + 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b35aa..72e08cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,24 +6,26 @@ | Windows | MacOS | Linux | | :--- | :--- | :--- | -| **MSI: [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Windows-x64.msi)**| **[Universal DMG](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-macOS.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.AppImage) | -| | **[Universal ZIP](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-macOS.zip)** | **DEB:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-amd64.deb) | -| | | **RPM:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.rpm) | -| | | **Flatpak:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.flatpak) | -| | | **TAR (Generic Linux):** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.tar.gz) | +| **MSI: [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Windows-x64.msi)**| **[Universal DMG](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-macOS.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-x86_64.AppImage) | +| | **[Universal ZIP](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-macOS.zip)** | **DEB:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-amd64.deb) | +| | | **RPM:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-x86_64.rpm) | +| | | **Flatpak:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-x86_64.flatpak) | +| | | **TAR (Generic Linux):** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-x86_64.tar.gz) | > [!IMPORTANT] The `.asc` files are my normal GPG signatures which you can verify using my GPG Public Key: https://tuxedo.rosie.run/GPG/BurntToasters_0xF2FBC20F_public.asc. ⚠️ Arm64 Linux and Windows Binaries are *NOT* available at the moment. Its something I may get around to in the future but its not a priority. *This app is currently unstable. Bugs, issues, and rough edges are expected.* +## Changes in `v0.9.0-beta.4:` + ## Changes in `v0.9.0-beta.3:` * **Updater:** Addressed an issue where the security policies on URLs did not have the new github redirect cdn added. (Beta users on 0.9.0 Beta 1 and Beta 2 need to manually update; sorry! Good thing for betas amiright :P) ## Changes in `v0.9.0-beta.2:` * **Linux:** Added AppImage and Flatpak support! Both are experimental until 0.9.0 is not in a beta. - * **AppImage:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.AppImage) — portable, no installation needed. - * **Flatpak:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.3/Dacx-Linux-x86_64.flatpak) — sandboxed package for app-store distributions (Flathub support planned). + * **AppImage:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-x86_64.AppImage) — portable, no installation needed. + * **Flatpak:** [x64](https://github.com/BurntToasters/Dacx/releases/download/v0.9.0-beta.4/Dacx-Linux-x86_64.flatpak) — sandboxed package for app-store distributions (Flathub support planned). ## Changes in `v0.9.0-beta.1:` ### UI - Major UI Overhaul! diff --git a/flatpak/run.rosie.dacx.yaml b/flatpak/run.rosie.dacx.yaml index d40f5fd..b1ebc76 100644 --- a/flatpak/run.rosie.dacx.yaml +++ b/flatpak/run.rosie.dacx.yaml @@ -1,4 +1,4 @@ -# x-version: 0.9.0-beta.3 +# x-version: 0.9.0-beta.4 app-id: run.rosie.dacx runtime: org.freedesktop.Platform runtime-version: "25.08" diff --git a/linux/packaging/control.template b/linux/packaging/control.template index cc4b15a..f590e4a 100644 --- a/linux/packaging/control.template +++ b/linux/packaging/control.template @@ -1,5 +1,5 @@ Package: dacx -Version: 0.9.0~beta.3 +Version: 0.9.0~beta.4 Section: sound Priority: optional Architecture: amd64 diff --git a/package-lock.json b/package-lock.json index 2217373..c86f475 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dacx", - "version": "0.9.0-beta.3", + "version": "0.9.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dacx", - "version": "0.9.0-beta.3", + "version": "0.9.0-beta.4", "license": "GPL-3.0-only", "devDependencies": { "cross-spawn": "^7.0.6", diff --git a/package.json b/package.json index 47a5fe0..b182f70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dacx", - "version": "0.9.0-beta.3", + "version": "0.9.0-beta.4", "private": true, "description": "Fast, lightweight cross-platform music and video player for Windows, macOS, and Linux.", "license": "GPL-3.0-only", diff --git a/pubspec.yaml b/pubspec.yaml index e525f24..9d6fade 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: dacx description: "Quick, lightweight cross-platform media player." publish_to: 'none' -version: 0.9.0-beta.3+900 +version: 0.9.0-beta.4+900 environment: sdk: ^3.10.7 diff --git a/run.rosie.dacx.metainfo.xml b/run.rosie.dacx.metainfo.xml index 0980ffe..3d7d03f 100644 --- a/run.rosie.dacx.metainfo.xml +++ b/run.rosie.dacx.metainfo.xml @@ -76,6 +76,7 @@ video/x-flv + From cd6e714804ec63cae0b62f461f769a7b7921f8c1 Mon Sep 17 00:00:00 2001 From: BurntToasters <61037367+BurntToasters@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:00:03 -0700 Subject: [PATCH 3/3] b4 --- CHANGELOG.md | 5 + flatpak/run.rosie.dacx.yaml | 1 + lib/l10n/app_en.arb | 100 ++++ lib/l10n/app_localizations.dart | 390 ++++++++++++++ lib/l10n/app_localizations_en.dart | 226 +++++++++ lib/models/playable_source.dart | 85 ++++ lib/playback/media_folder_scanner.dart | 159 ++++++ lib/screens/player_screen.dart | 477 +++++++++++++++--- lib/screens/settings_screen.dart | 8 +- lib/services/debug_log_service.dart | 36 +- lib/services/playlist_service.dart | 103 +++- lib/services/settings_service.dart | 7 + lib/widgets/compact_exit_button.dart | 7 +- lib/widgets/debug_log_panel.dart | 55 +- lib/widgets/media_info_dialog.dart | 78 +++ lib/widgets/open_url_dialog.dart | 82 +++ lib/widgets/seek_slider.dart | 3 +- lib/widgets/transport_controls.dart | 28 +- lib/widgets/update_progress_dialog.dart | 83 ++- macos/Runner/DebugProfile.entitlements | 4 +- macos/Runner/Release.entitlements | 4 +- pubspec.lock | 4 +- test/models/playable_source_test.dart | 84 +++ test/playback/media_folder_scanner_test.dart | 77 +++ test/services/debug_log_service_test.dart | 24 + test/services/playlist_service_test.dart | 63 ++- test/services/settings_service_test.dart | 57 +++ test/widgets/media_info_dialog_test.dart | 60 +++ test/widgets/open_url_dialog_test.dart | 78 +++ test/widgets/seek_slider_test.dart | 3 + test/widgets/transport_controls_test.dart | 67 +++ test/widgets/update_progress_dialog_test.dart | 25 +- 32 files changed, 2279 insertions(+), 204 deletions(-) create mode 100644 lib/models/playable_source.dart create mode 100644 lib/playback/media_folder_scanner.dart create mode 100644 lib/widgets/media_info_dialog.dart create mode 100644 lib/widgets/open_url_dialog.dart create mode 100644 test/models/playable_source_test.dart create mode 100644 test/playback/media_folder_scanner_test.dart create mode 100644 test/widgets/media_info_dialog_test.dart create mode 100644 test/widgets/open_url_dialog_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e08cf..87aed45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ The `.asc` files are my normal GPG signatures which you can verify using my GPG ## Changes in `v0.9.0-beta.4:` +- **NEW - Localization completeness:** All user-facing hardcoded strings are now localized. Covered in this pass: transport control tooltips ("Previous Track", "Next Track", "Play Queue"); folder + URL button and dialog labels; media info metadata labels (Source, Duration, Resolution, Audio Tracks, etc.); folder scan and queue-truncation error feedback; update progress dialog (installing/progress/failure states and all error-outcome messages); post-update result snackbars; debug log panel UI (title, buttons, empty state); accessibility `Semantics` labels (seek bar, accent color picker, mini-player exit button). Previously orphaned `snackDebugLogCopied`/`snackDebugLogCleared` keys are now used. +- **Testing:** 342 tests passing. Code verified clean with zero lint issues. +- **Codebase:** All l10n keys auto-generated via `flutter gen-l10n`. +- **PKG:** Updated packages. + ## Changes in `v0.9.0-beta.3:` * **Updater:** Addressed an issue where the security policies on URLs did not have the new github redirect cdn added. (Beta users on 0.9.0 Beta 1 and Beta 2 need to manually update; sorry! Good thing for betas amiright :P) diff --git a/flatpak/run.rosie.dacx.yaml b/flatpak/run.rosie.dacx.yaml index b1ebc76..4a595fb 100644 --- a/flatpak/run.rosie.dacx.yaml +++ b/flatpak/run.rosie.dacx.yaml @@ -14,6 +14,7 @@ finish-args: - --filesystem=xdg-music:ro - --filesystem=xdg-videos:ro - --filesystem=xdg-download:ro + - --filesystem=xdg-pictures:create # Arbitrary paths use the Freedesktop file portal (file_picker). - --talk-name=org.freedesktop.Notifications - --talk-name=org.mpris.MediaPlayer2.dacx diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 26475a0..1eb51c8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -150,9 +150,25 @@ "@snackSkippedUnreadableFiles": { "placeholders": { "count": { "type": "int", "example": "3" } } }, + "snackInvalidStreamUrl": "Enter a valid http:// or https:// URL.", + "snackNoSupportedMediaInFolder": "No supported media found in that folder.", + "snackFolderScanFailed": "Could not scan folder. {detail}", + "@snackFolderScanFailed": { + "placeholders": { "detail": { "type": "String", "example": "Permission denied" } } + }, + "snackFolderScanSkipped": "Skipped {count} unsupported or unreadable item(s).", + "@snackFolderScanSkipped": { + "placeholders": { "count": { "type": "int", "example": "4" } } + }, + "snackQueueRemovedMissing": "Removed {count} missing item(s).", + "@snackQueueRemovedMissing": { + "placeholders": { "count": { "type": "int", "example": "2" } } + }, "emptyStateMessage": "Drop a file here or click Open", "buttonOpenFile": "Open File", + "buttonOpenFolder": "Open Folder", + "buttonOpenUrl": "Open URL", "buttonReopenLast": "Reopen Last", "dialogAudioTrackTitle": "Audio track", @@ -165,8 +181,24 @@ "dialogPlayQueueAddFiles": "Add files…", "dialogKeyboardShortcutsTitle": "Keyboard shortcuts", "dialogKeyCaptureTitle": "Press a key combination", + "dialogOpenUrlTitle": "Open URL", + "dialogOpenUrlHint": "https://example.com/stream.m3u8", + "dialogMediaInfoTitle": "Media info", "dialogMacInstallLocationTitle": "Move Dacx to Applications", "dialogMacInstallLocationMessage": "Dacx is meant to run from /Applications/Dacx.app. Move it to the Applications folder for the best update experience.", + "mediaInfoSource": "Source", + "mediaInfoType": "Type", + "mediaInfoDuration": "Duration", + "mediaInfoResolution": "Resolution", + "mediaInfoAudioTracks": "Audio tracks", + "mediaInfoSubtitleTracks": "Subtitle tracks", + "mediaInfoChapters": "Chapters", + "mediaInfoAudioSelection": "Selected audio", + "mediaInfoSubtitleSelection": "Selected subtitles", + "mediaInfoTypeUrlStream": "URL stream", + "mediaInfoTypeAudioFile": "Audio file", + "mediaInfoTypeVideoFile": "Video file", + "mediaInfoUnknown": "Unknown", "menuTakeScreenshot": "Take screenshot", "menuMixAllAudioTracks": "Mix all audio tracks", @@ -213,7 +245,9 @@ "actionClear": "Clear", "actionCancel": "Cancel", "actionSave": "Save", + "actionOpen": "Open", "actionRemove": "Remove", + "actionRemoveMissing": "Remove missing", "actionSetNewBinding": "Set new binding", "actionResetToDefault": "Reset to default", @@ -224,7 +258,73 @@ "tooltipMore": "More", "tooltipSettings": "Settings", "tooltipOpenFile": "Open file", + "tooltipOpenFolder": "Open folder", + "tooltipOpenUrl": "Open URL", "tooltipRecentFiles": "Recent files", + "tooltipMediaInfo": "Media info", + "tooltipPreviousTrack": "Previous Track (PageUp)", + "tooltipNextTrack": "Next Track (PageDown)", + "tooltipPlayQueue": "Play Queue", + "tooltipExitMiniPlayer": "Exit mini-player", + + "semanticsSeekBar": "Seek bar", + "semanticsAccentColor": "Accent color {name}", + "@semanticsAccentColor": { + "placeholders": { "name": { "type": "String" } } + }, + + "updateDialogInstallingTitle": "Installing Dacx {version}", + "@updateDialogInstallingTitle": { + "placeholders": { "version": { "type": "String", "example": "0.9.0" } } + }, + "updateDialogDownloadingVerifying": "Downloading and verifying in the update helper...", + "updateDialogVerifyingSignature": "Verifying signature...", + "updateDialogDownloadingProgress": "Downloading {downloaded} / {total}", + "@updateDialogDownloadingProgress": { + "placeholders": { + "downloaded": { "type": "String", "example": "1.2 MB" }, + "total": { "type": "String", "example": "8.4 MB" } + } + }, + "updateDialogDownloading": "Downloading...", + "updateDialogWillClose": "Dacx will close to apply the update.", + "updateDialogFailedTitle": "Update failed", + "updateDialogOpenReleasePage": "Open release page", + "updateActionInstall": "Install", + "updateActionView": "View", + + "snackUpdatedToVersion": "Updated to v{version}", + "@snackUpdatedToVersion": { + "placeholders": { "version": { "type": "String", "example": "0.9.0" } } + }, + "snackUpdateMayHaveFailed": "Update to v{version} may have failed.", + "@snackUpdateMayHaveFailed": { + "placeholders": { "version": { "type": "String", "example": "0.9.0" } } + }, + + "debugLogTitle": "Debug Log", + "debugLogEntryCount": "{count} entries", + "@debugLogEntryCount": { + "placeholders": { "count": { "type": "int" } } + }, + "debugLogCopyButton": "Copy Log", + "debugLogClearButton": "Clear Log", + "debugLogEmpty": "No debug events yet.", + + "updateOutcomeUnsupportedPlatform": "Self-update is not supported on this platform.", + "updateOutcomeMissingAsset": "The release does not include an installer for this platform.", + "updateOutcomeMissingChecksums": "The release does not include a checksums file. Cannot verify download.", + "updateOutcomeMissingSignature": "The release does not include a signed update manifest. Cannot verify update authenticity.", + "updateOutcomeDownloadFailed": "Download failed.", + "updateOutcomeChecksumMismatch": "Downloaded file failed checksum verification. Refusing to install.", + "updateOutcomeExtractionFailed": "Could not extract the update package.", + "updateOutcomeSignatureInvalid": "Downloaded app failed code-signature verification.", + "updateOutcomeBundleIdMismatch": "Downloaded app has an unexpected bundle identifier. Refusing to install.", + "updateOutcomeVersionMismatch": "Downloaded app version does not match the selected update. Refusing to install.", + "updateOutcomeTeamIdMismatch": "Downloaded app is signed by an unexpected developer. Refusing to install.", + "updateOutcomeGatekeeperRejected": "Self-update is not available on this build (missing signing configuration).", + "updateOutcomeSpawnFailed": "Could not launch the installer.", + "updateOutcomeStarted": "Update started.", "windowMinimize": "Minimize window", "windowMaximize": "Maximize window", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 461934d..1a43abe 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -724,6 +724,36 @@ abstract class AppLocalizations { /// **'Skipped {count} unreadable files.'** String snackSkippedUnreadableFiles(int count); + /// No description provided for @snackInvalidStreamUrl. + /// + /// In en, this message translates to: + /// **'Enter a valid http:// or https:// URL.'** + String get snackInvalidStreamUrl; + + /// No description provided for @snackNoSupportedMediaInFolder. + /// + /// In en, this message translates to: + /// **'No supported media found in that folder.'** + String get snackNoSupportedMediaInFolder; + + /// No description provided for @snackFolderScanFailed. + /// + /// In en, this message translates to: + /// **'Could not scan folder. {detail}'** + String snackFolderScanFailed(String detail); + + /// No description provided for @snackFolderScanSkipped. + /// + /// In en, this message translates to: + /// **'Skipped {count} unsupported or unreadable item(s).'** + String snackFolderScanSkipped(int count); + + /// No description provided for @snackQueueRemovedMissing. + /// + /// In en, this message translates to: + /// **'Removed {count} missing item(s).'** + String snackQueueRemovedMissing(int count); + /// No description provided for @emptyStateMessage. /// /// In en, this message translates to: @@ -736,6 +766,18 @@ abstract class AppLocalizations { /// **'Open File'** String get buttonOpenFile; + /// No description provided for @buttonOpenFolder. + /// + /// In en, this message translates to: + /// **'Open Folder'** + String get buttonOpenFolder; + + /// No description provided for @buttonOpenUrl. + /// + /// In en, this message translates to: + /// **'Open URL'** + String get buttonOpenUrl; + /// No description provided for @buttonReopenLast. /// /// In en, this message translates to: @@ -802,6 +844,24 @@ abstract class AppLocalizations { /// **'Press a key combination'** String get dialogKeyCaptureTitle; + /// No description provided for @dialogOpenUrlTitle. + /// + /// In en, this message translates to: + /// **'Open URL'** + String get dialogOpenUrlTitle; + + /// No description provided for @dialogOpenUrlHint. + /// + /// In en, this message translates to: + /// **'https://example.com/stream.m3u8'** + String get dialogOpenUrlHint; + + /// No description provided for @dialogMediaInfoTitle. + /// + /// In en, this message translates to: + /// **'Media info'** + String get dialogMediaInfoTitle; + /// No description provided for @dialogMacInstallLocationTitle. /// /// In en, this message translates to: @@ -814,6 +874,84 @@ abstract class AppLocalizations { /// **'Dacx is meant to run from /Applications/Dacx.app. Move it to the Applications folder for the best update experience.'** String get dialogMacInstallLocationMessage; + /// No description provided for @mediaInfoSource. + /// + /// In en, this message translates to: + /// **'Source'** + String get mediaInfoSource; + + /// No description provided for @mediaInfoType. + /// + /// In en, this message translates to: + /// **'Type'** + String get mediaInfoType; + + /// No description provided for @mediaInfoDuration. + /// + /// In en, this message translates to: + /// **'Duration'** + String get mediaInfoDuration; + + /// No description provided for @mediaInfoResolution. + /// + /// In en, this message translates to: + /// **'Resolution'** + String get mediaInfoResolution; + + /// No description provided for @mediaInfoAudioTracks. + /// + /// In en, this message translates to: + /// **'Audio tracks'** + String get mediaInfoAudioTracks; + + /// No description provided for @mediaInfoSubtitleTracks. + /// + /// In en, this message translates to: + /// **'Subtitle tracks'** + String get mediaInfoSubtitleTracks; + + /// No description provided for @mediaInfoChapters. + /// + /// In en, this message translates to: + /// **'Chapters'** + String get mediaInfoChapters; + + /// No description provided for @mediaInfoAudioSelection. + /// + /// In en, this message translates to: + /// **'Selected audio'** + String get mediaInfoAudioSelection; + + /// No description provided for @mediaInfoSubtitleSelection. + /// + /// In en, this message translates to: + /// **'Selected subtitles'** + String get mediaInfoSubtitleSelection; + + /// No description provided for @mediaInfoTypeUrlStream. + /// + /// In en, this message translates to: + /// **'URL stream'** + String get mediaInfoTypeUrlStream; + + /// No description provided for @mediaInfoTypeAudioFile. + /// + /// In en, this message translates to: + /// **'Audio file'** + String get mediaInfoTypeAudioFile; + + /// No description provided for @mediaInfoTypeVideoFile. + /// + /// In en, this message translates to: + /// **'Video file'** + String get mediaInfoTypeVideoFile; + + /// No description provided for @mediaInfoUnknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get mediaInfoUnknown; + /// No description provided for @menuTakeScreenshot. /// /// In en, this message translates to: @@ -1024,12 +1162,24 @@ abstract class AppLocalizations { /// **'Save'** String get actionSave; + /// No description provided for @actionOpen. + /// + /// In en, this message translates to: + /// **'Open'** + String get actionOpen; + /// No description provided for @actionRemove. /// /// In en, this message translates to: /// **'Remove'** String get actionRemove; + /// No description provided for @actionRemoveMissing. + /// + /// In en, this message translates to: + /// **'Remove missing'** + String get actionRemoveMissing; + /// No description provided for @actionSetNewBinding. /// /// In en, this message translates to: @@ -1078,12 +1228,252 @@ abstract class AppLocalizations { /// **'Open file'** String get tooltipOpenFile; + /// No description provided for @tooltipOpenFolder. + /// + /// In en, this message translates to: + /// **'Open folder'** + String get tooltipOpenFolder; + + /// No description provided for @tooltipOpenUrl. + /// + /// In en, this message translates to: + /// **'Open URL'** + String get tooltipOpenUrl; + /// No description provided for @tooltipRecentFiles. /// /// In en, this message translates to: /// **'Recent files'** String get tooltipRecentFiles; + /// No description provided for @tooltipMediaInfo. + /// + /// In en, this message translates to: + /// **'Media info'** + String get tooltipMediaInfo; + + /// No description provided for @tooltipPreviousTrack. + /// + /// In en, this message translates to: + /// **'Previous Track (PageUp)'** + String get tooltipPreviousTrack; + + /// No description provided for @tooltipNextTrack. + /// + /// In en, this message translates to: + /// **'Next Track (PageDown)'** + String get tooltipNextTrack; + + /// No description provided for @tooltipPlayQueue. + /// + /// In en, this message translates to: + /// **'Play Queue'** + String get tooltipPlayQueue; + + /// No description provided for @tooltipExitMiniPlayer. + /// + /// In en, this message translates to: + /// **'Exit mini-player'** + String get tooltipExitMiniPlayer; + + /// No description provided for @semanticsSeekBar. + /// + /// In en, this message translates to: + /// **'Seek bar'** + String get semanticsSeekBar; + + /// No description provided for @semanticsAccentColor. + /// + /// In en, this message translates to: + /// **'Accent color {name}'** + String semanticsAccentColor(String name); + + /// No description provided for @updateDialogInstallingTitle. + /// + /// In en, this message translates to: + /// **'Installing Dacx {version}'** + String updateDialogInstallingTitle(String version); + + /// No description provided for @updateDialogDownloadingVerifying. + /// + /// In en, this message translates to: + /// **'Downloading and verifying in the update helper...'** + String get updateDialogDownloadingVerifying; + + /// No description provided for @updateDialogVerifyingSignature. + /// + /// In en, this message translates to: + /// **'Verifying signature...'** + String get updateDialogVerifyingSignature; + + /// No description provided for @updateDialogDownloadingProgress. + /// + /// In en, this message translates to: + /// **'Downloading {downloaded} / {total}'** + String updateDialogDownloadingProgress(String downloaded, String total); + + /// No description provided for @updateDialogDownloading. + /// + /// In en, this message translates to: + /// **'Downloading...'** + String get updateDialogDownloading; + + /// No description provided for @updateDialogWillClose. + /// + /// In en, this message translates to: + /// **'Dacx will close to apply the update.'** + String get updateDialogWillClose; + + /// No description provided for @updateDialogFailedTitle. + /// + /// In en, this message translates to: + /// **'Update failed'** + String get updateDialogFailedTitle; + + /// No description provided for @updateDialogOpenReleasePage. + /// + /// In en, this message translates to: + /// **'Open release page'** + String get updateDialogOpenReleasePage; + + /// No description provided for @updateActionInstall. + /// + /// In en, this message translates to: + /// **'Install'** + String get updateActionInstall; + + /// No description provided for @updateActionView. + /// + /// In en, this message translates to: + /// **'View'** + String get updateActionView; + + /// No description provided for @snackUpdatedToVersion. + /// + /// In en, this message translates to: + /// **'Updated to v{version}'** + String snackUpdatedToVersion(String version); + + /// No description provided for @snackUpdateMayHaveFailed. + /// + /// In en, this message translates to: + /// **'Update to v{version} may have failed.'** + String snackUpdateMayHaveFailed(String version); + + /// No description provided for @debugLogTitle. + /// + /// In en, this message translates to: + /// **'Debug Log'** + String get debugLogTitle; + + /// No description provided for @debugLogEntryCount. + /// + /// In en, this message translates to: + /// **'{count} entries'** + String debugLogEntryCount(int count); + + /// No description provided for @debugLogCopyButton. + /// + /// In en, this message translates to: + /// **'Copy Log'** + String get debugLogCopyButton; + + /// No description provided for @debugLogClearButton. + /// + /// In en, this message translates to: + /// **'Clear Log'** + String get debugLogClearButton; + + /// No description provided for @debugLogEmpty. + /// + /// In en, this message translates to: + /// **'No debug events yet.'** + String get debugLogEmpty; + + /// No description provided for @updateOutcomeUnsupportedPlatform. + /// + /// In en, this message translates to: + /// **'Self-update is not supported on this platform.'** + String get updateOutcomeUnsupportedPlatform; + + /// No description provided for @updateOutcomeMissingAsset. + /// + /// In en, this message translates to: + /// **'The release does not include an installer for this platform.'** + String get updateOutcomeMissingAsset; + + /// No description provided for @updateOutcomeMissingChecksums. + /// + /// In en, this message translates to: + /// **'The release does not include a checksums file. Cannot verify download.'** + String get updateOutcomeMissingChecksums; + + /// No description provided for @updateOutcomeMissingSignature. + /// + /// In en, this message translates to: + /// **'The release does not include a signed update manifest. Cannot verify update authenticity.'** + String get updateOutcomeMissingSignature; + + /// No description provided for @updateOutcomeDownloadFailed. + /// + /// In en, this message translates to: + /// **'Download failed.'** + String get updateOutcomeDownloadFailed; + + /// No description provided for @updateOutcomeChecksumMismatch. + /// + /// In en, this message translates to: + /// **'Downloaded file failed checksum verification. Refusing to install.'** + String get updateOutcomeChecksumMismatch; + + /// No description provided for @updateOutcomeExtractionFailed. + /// + /// In en, this message translates to: + /// **'Could not extract the update package.'** + String get updateOutcomeExtractionFailed; + + /// No description provided for @updateOutcomeSignatureInvalid. + /// + /// In en, this message translates to: + /// **'Downloaded app failed code-signature verification.'** + String get updateOutcomeSignatureInvalid; + + /// No description provided for @updateOutcomeBundleIdMismatch. + /// + /// In en, this message translates to: + /// **'Downloaded app has an unexpected bundle identifier. Refusing to install.'** + String get updateOutcomeBundleIdMismatch; + + /// No description provided for @updateOutcomeVersionMismatch. + /// + /// In en, this message translates to: + /// **'Downloaded app version does not match the selected update. Refusing to install.'** + String get updateOutcomeVersionMismatch; + + /// No description provided for @updateOutcomeTeamIdMismatch. + /// + /// In en, this message translates to: + /// **'Downloaded app is signed by an unexpected developer. Refusing to install.'** + String get updateOutcomeTeamIdMismatch; + + /// No description provided for @updateOutcomeGatekeeperRejected. + /// + /// In en, this message translates to: + /// **'Self-update is not available on this build (missing signing configuration).'** + String get updateOutcomeGatekeeperRejected; + + /// No description provided for @updateOutcomeSpawnFailed. + /// + /// In en, this message translates to: + /// **'Could not launch the installer.'** + String get updateOutcomeSpawnFailed; + + /// No description provided for @updateOutcomeStarted. + /// + /// In en, this message translates to: + /// **'Update started.'** + String get updateOutcomeStarted; + /// No description provided for @windowMinimize. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c6f021d..c500c6b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -375,12 +375,40 @@ class AppLocalizationsEn extends AppLocalizations { return 'Skipped $count unreadable files.'; } + @override + String get snackInvalidStreamUrl => 'Enter a valid http:// or https:// URL.'; + + @override + String get snackNoSupportedMediaInFolder => + 'No supported media found in that folder.'; + + @override + String snackFolderScanFailed(String detail) { + return 'Could not scan folder. $detail'; + } + + @override + String snackFolderScanSkipped(int count) { + return 'Skipped $count unsupported or unreadable item(s).'; + } + + @override + String snackQueueRemovedMissing(int count) { + return 'Removed $count missing item(s).'; + } + @override String get emptyStateMessage => 'Drop a file here or click Open'; @override String get buttonOpenFile => 'Open File'; + @override + String get buttonOpenFolder => 'Open Folder'; + + @override + String get buttonOpenUrl => 'Open URL'; + @override String get buttonReopenLast => 'Reopen Last'; @@ -414,6 +442,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dialogKeyCaptureTitle => 'Press a key combination'; + @override + String get dialogOpenUrlTitle => 'Open URL'; + + @override + String get dialogOpenUrlHint => 'https://example.com/stream.m3u8'; + + @override + String get dialogMediaInfoTitle => 'Media info'; + @override String get dialogMacInstallLocationTitle => 'Move Dacx to Applications'; @@ -421,6 +458,45 @@ class AppLocalizationsEn extends AppLocalizations { String get dialogMacInstallLocationMessage => 'Dacx is meant to run from /Applications/Dacx.app. Move it to the Applications folder for the best update experience.'; + @override + String get mediaInfoSource => 'Source'; + + @override + String get mediaInfoType => 'Type'; + + @override + String get mediaInfoDuration => 'Duration'; + + @override + String get mediaInfoResolution => 'Resolution'; + + @override + String get mediaInfoAudioTracks => 'Audio tracks'; + + @override + String get mediaInfoSubtitleTracks => 'Subtitle tracks'; + + @override + String get mediaInfoChapters => 'Chapters'; + + @override + String get mediaInfoAudioSelection => 'Selected audio'; + + @override + String get mediaInfoSubtitleSelection => 'Selected subtitles'; + + @override + String get mediaInfoTypeUrlStream => 'URL stream'; + + @override + String get mediaInfoTypeAudioFile => 'Audio file'; + + @override + String get mediaInfoTypeVideoFile => 'Video file'; + + @override + String get mediaInfoUnknown => 'Unknown'; + @override String get menuTakeScreenshot => 'Take screenshot'; @@ -543,9 +619,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionSave => 'Save'; + @override + String get actionOpen => 'Open'; + @override String get actionRemove => 'Remove'; + @override + String get actionRemoveMissing => 'Remove missing'; + @override String get actionSetNewBinding => 'Set new binding'; @@ -570,9 +652,153 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tooltipOpenFile => 'Open file'; + @override + String get tooltipOpenFolder => 'Open folder'; + + @override + String get tooltipOpenUrl => 'Open URL'; + @override String get tooltipRecentFiles => 'Recent files'; + @override + String get tooltipMediaInfo => 'Media info'; + + @override + String get tooltipPreviousTrack => 'Previous Track (PageUp)'; + + @override + String get tooltipNextTrack => 'Next Track (PageDown)'; + + @override + String get tooltipPlayQueue => 'Play Queue'; + + @override + String get tooltipExitMiniPlayer => 'Exit mini-player'; + + @override + String get semanticsSeekBar => 'Seek bar'; + + @override + String semanticsAccentColor(String name) { + return 'Accent color $name'; + } + + @override + String updateDialogInstallingTitle(String version) { + return 'Installing Dacx $version'; + } + + @override + String get updateDialogDownloadingVerifying => + 'Downloading and verifying in the update helper...'; + + @override + String get updateDialogVerifyingSignature => 'Verifying signature...'; + + @override + String updateDialogDownloadingProgress(String downloaded, String total) { + return 'Downloading $downloaded / $total'; + } + + @override + String get updateDialogDownloading => 'Downloading...'; + + @override + String get updateDialogWillClose => 'Dacx will close to apply the update.'; + + @override + String get updateDialogFailedTitle => 'Update failed'; + + @override + String get updateDialogOpenReleasePage => 'Open release page'; + + @override + String get updateActionInstall => 'Install'; + + @override + String get updateActionView => 'View'; + + @override + String snackUpdatedToVersion(String version) { + return 'Updated to v$version'; + } + + @override + String snackUpdateMayHaveFailed(String version) { + return 'Update to v$version may have failed.'; + } + + @override + String get debugLogTitle => 'Debug Log'; + + @override + String debugLogEntryCount(int count) { + return '$count entries'; + } + + @override + String get debugLogCopyButton => 'Copy Log'; + + @override + String get debugLogClearButton => 'Clear Log'; + + @override + String get debugLogEmpty => 'No debug events yet.'; + + @override + String get updateOutcomeUnsupportedPlatform => + 'Self-update is not supported on this platform.'; + + @override + String get updateOutcomeMissingAsset => + 'The release does not include an installer for this platform.'; + + @override + String get updateOutcomeMissingChecksums => + 'The release does not include a checksums file. Cannot verify download.'; + + @override + String get updateOutcomeMissingSignature => + 'The release does not include a signed update manifest. Cannot verify update authenticity.'; + + @override + String get updateOutcomeDownloadFailed => 'Download failed.'; + + @override + String get updateOutcomeChecksumMismatch => + 'Downloaded file failed checksum verification. Refusing to install.'; + + @override + String get updateOutcomeExtractionFailed => + 'Could not extract the update package.'; + + @override + String get updateOutcomeSignatureInvalid => + 'Downloaded app failed code-signature verification.'; + + @override + String get updateOutcomeBundleIdMismatch => + 'Downloaded app has an unexpected bundle identifier. Refusing to install.'; + + @override + String get updateOutcomeVersionMismatch => + 'Downloaded app version does not match the selected update. Refusing to install.'; + + @override + String get updateOutcomeTeamIdMismatch => + 'Downloaded app is signed by an unexpected developer. Refusing to install.'; + + @override + String get updateOutcomeGatekeeperRejected => + 'Self-update is not available on this build (missing signing configuration).'; + + @override + String get updateOutcomeSpawnFailed => 'Could not launch the installer.'; + + @override + String get updateOutcomeStarted => 'Update started.'; + @override String get windowMinimize => 'Minimize window'; diff --git a/lib/models/playable_source.dart b/lib/models/playable_source.dart new file mode 100644 index 0000000..5b5c334 --- /dev/null +++ b/lib/models/playable_source.dart @@ -0,0 +1,85 @@ +import 'package:path/path.dart' as p; + +enum PlayableSourceType { file, url } + +class PlayableSource { + const PlayableSource._(this.type, this.value); + + factory PlayableSource.file(String path) => + PlayableSource._(PlayableSourceType.file, path.trim()); + + factory PlayableSource.url(String url) => + PlayableSource._(PlayableSourceType.url, url.trim()); + + final PlayableSourceType type; + final String value; + + bool get isFile => type == PlayableSourceType.file; + bool get isUrl => type == PlayableSourceType.url; + + String get displayName { + if (isFile) { + final name = p.basename(value).trim(); + return name.isEmpty ? value : name; + } + final uri = Uri.tryParse(value); + if (uri == null) return value; + final pathName = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last; + if (pathName.trim().isNotEmpty) return pathName.trim(); + return uri.host.isEmpty ? value : uri.host; + } + + String? get extension { + final sourcePath = isUrl ? Uri.tryParse(value)?.path ?? value : value; + return p.extension(sourcePath).toLowerCase().replaceFirst('.', ''); + } + + static PlayableSource? fromStored(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (isSupportedUrl(trimmed)) return PlayableSource.url(trimmed); + return PlayableSource.file(trimmed); + } + + static bool isSupportedUrl(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) return false; + return uri.scheme == 'http' || uri.scheme == 'https'; + } + + static bool isDisplaySafeUrl(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || !isSupportedUrl(value)) return false; + return uri.userInfo.isEmpty && uri.query.isEmpty && uri.fragment.isEmpty; + } + + static String displaySafeUrl(String value) { + final trimmed = value.trim(); + final uri = Uri.tryParse(trimmed); + if (uri == null || !isSupportedUrl(trimmed)) return trimmed; + var base = uri.replace(userInfo: '').toString(); + final queryIndex = base.indexOf('?'); + final fragmentIndex = base.indexOf('#'); + final cutIndexes = [queryIndex, fragmentIndex].where((index) => index >= 0); + if (cutIndexes.isNotEmpty) { + base = base.substring(0, cutIndexes.reduce((a, b) => a < b ? a : b)); + } + if (uri.query.isNotEmpty) base = '$base?'; + if (uri.fragment.isNotEmpty) base = '$base#'; + return base; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlayableSource && + runtimeType == other.runtimeType && + type == other.type && + value == other.value; + + @override + int get hashCode => Object.hash(type, value); + + @override + String toString() => value; +} diff --git a/lib/playback/media_folder_scanner.dart b/lib/playback/media_folder_scanner.dart new file mode 100644 index 0000000..4ecab62 --- /dev/null +++ b/lib/playback/media_folder_scanner.dart @@ -0,0 +1,159 @@ +import 'dart:io'; +import 'dart:isolate'; + +import 'package:path/path.dart' as p; + +import 'player_path_utils.dart'; + +class MediaFolderScanResult { + const MediaFolderScanResult({ + required this.paths, + required this.skipped, + required this.truncated, + }); + + final List paths; + final int skipped; + final int truncated; +} + +abstract final class MediaFolderScanner { + static Future scan( + String folderPath, { + int maxItems = 1000, + }) { + return Isolate.run(() => _scanImpl(folderPath.trim(), maxItems)); + } +} + +Future _scanImpl(String folderPath, int maxItems) async { + final root = Directory(folderPath); + if (!root.existsSync()) { + return const MediaFolderScanResult(paths: [], skipped: 0, truncated: 0); + } + + final limit = maxItems < 0 ? 0 : maxItems; + final paths = []; + var skipped = 0; + var supported = 0; + var pathsAreSorted = false; + final stream = root + .list(recursive: true, followLinks: false) + .handleError( + (_) => skipped++, + test: (error) => error is FileSystemException, + ); + + await for (final entity in stream) { + if (entity is! File) continue; + final path = entity.path.trim(); + final ext = p.extension(path).toLowerCase().replaceFirst('.', ''); + if (!PlayerPathUtils.isSupportedExtension(ext)) { + skipped++; + continue; + } + supported++; + if (limit == 0) continue; + if (paths.length < limit) { + paths.add(path); + if (paths.length == limit) { + paths.sort(_comparePaths); + pathsAreSorted = true; + } + continue; + } + if (!pathsAreSorted) { + paths.sort(_comparePaths); + pathsAreSorted = true; + } + if (_comparePaths(path, paths.last) >= 0) continue; + final insertAt = _lowerBound(paths, path); + paths.insert(insertAt, path); + paths.removeLast(); + } + + if (!pathsAreSorted) { + paths.sort(_comparePaths); + } + final truncated = supported > paths.length ? supported - paths.length : 0; + return MediaFolderScanResult( + paths: List.unmodifiable(paths), + skipped: skipped, + truncated: truncated, + ); +} + +int _comparePaths(String a, String b) { + final natural = _naturalCompare(a.toLowerCase(), b.toLowerCase()); + // Deterministic tie-break for paths that differ only by case. + return natural != 0 ? natural : a.compareTo(b); +} + +/// Natural/numeric-aware comparison: digit runs are compared as integers so +/// "track2" sorts before "track10". +int _naturalCompare(String a, String b) { + var i = 0; + var j = 0; + while (i < a.length && j < b.length) { + final ca = a.codeUnitAt(i); + final cb = b.codeUnitAt(j); + final aDigit = ca >= 0x30 && ca <= 0x39; + final bDigit = cb >= 0x30 && cb <= 0x39; + if (aDigit && bDigit) { + // Extract full digit runs from both sides. + final aStart = i; + final bStart = j; + while (i < a.length && + a.codeUnitAt(i) >= 0x30 && + a.codeUnitAt(i) <= 0x39) { + i++; + } + while (j < b.length && + b.codeUnitAt(j) >= 0x30 && + b.codeUnitAt(j) <= 0x39) { + j++; + } + final aLen = i - aStart; + final bLen = j - bStart; + // Compare numerically: shorter digit run (sans leading zeros) is smaller. + // Strip leading zeros for value comparison. + var aNumStart = aStart; + var bNumStart = bStart; + while (aNumStart < i - 1 && a.codeUnitAt(aNumStart) == 0x30) { + aNumStart++; + } + while (bNumStart < j - 1 && b.codeUnitAt(bNumStart) == 0x30) { + bNumStart++; + } + final aEffLen = i - aNumStart; + final bEffLen = j - bNumStart; + if (aEffLen != bEffLen) return aEffLen - bEffLen; + // Same effective length — compare digit by digit. + for (var k = 0; k < aEffLen; k++) { + final diff = a.codeUnitAt(aNumStart + k) - b.codeUnitAt(bNumStart + k); + if (diff != 0) return diff; + } + // Same numeric value — shorter original (fewer leading zeros) wins. + if (aLen != bLen) return aLen - bLen; + } else { + if (ca != cb) return ca - cb; + i++; + j++; + } + } + return a.length - b.length; +} + +int _lowerBound(List sortedPaths, String path) { + var low = 0; + var high = sortedPaths.length; + while (low < high) { + final mid = low + ((high - low) >> 1); + if (_comparePaths(sortedPaths[mid], path) < 0) { + low = mid + 1; + } else { + high = mid; + } + } + return low; +} diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 18dc67e..0e7aac6 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -24,10 +24,12 @@ import '../services/playlist_service.dart'; import '../services/bookmark_service.dart'; import '../services/seek_preview_service.dart'; import '../services/self_update_service.dart'; +import '../models/playable_source.dart'; import '../l10n/app_localizations.dart'; import '../theme/window_visuals.dart'; import '../services/update_service.dart'; import '../playback/chapter_list_loader.dart'; +import '../playback/media_folder_scanner.dart'; import '../playback/playback_controller.dart'; import '../playback/playback_mix_policy.dart'; import '../playback/player_path_utils.dart'; @@ -35,6 +37,8 @@ import '../playback/track_label.dart'; import '../playback/subscription_bag.dart'; import '../widgets/compact_exit_button.dart'; import '../widgets/custom_title_bar.dart'; +import '../widgets/media_info_dialog.dart'; +import '../widgets/open_url_dialog.dart'; import '../widgets/osd_overlay.dart'; import '../widgets/update_progress_dialog.dart'; import '../widgets/seek_slider.dart'; @@ -93,7 +97,8 @@ class _PlayerScreenState extends State { SettingsService get _settings => widget.settings; - String? _currentFile; + PlayableSource? _currentSource; + String? get _currentFile => _currentSource?.value; bool _isDragging = false; bool _isAudioFile = false; bool _hasVideoOutput = false; @@ -245,11 +250,11 @@ class _PlayerScreenState extends State { if (!mounted || _isDisposed) return; setState(() => _duration = dur); if (dur.inMilliseconds > 0 && _settings.mediaSessionEnabled) { - final path = _currentFile; - if (path != null) { + final source = _currentSource; + if (source != null) { unawaited( _mediaSession.updateMetadata( - title: p.basenameWithoutExtension(path), + title: source.displayName, duration: dur, ), ); @@ -368,8 +373,9 @@ class _PlayerScreenState extends State { _log('playback_completed'); } // File ran to end → drop saved resume position. - if (_currentFile != null) { - _settings.saveResumePosition(_currentFile!, null); + final source = _currentSource; + if (source != null && source.isFile) { + _settings.saveResumePosition(source.value, null); } // Try to advance the playlist (loop-mode `none` only). if (_settings.loopMode == LoopMode.none) { @@ -760,7 +766,11 @@ class _PlayerScreenState extends State { detailsBuilder: () => {'version': targetVersion}, ); messenger.showSnackBar( - SnackBar(content: Text('Updated to v$targetVersion')), + SnackBar( + content: Text( + AppLocalizations.of(context).snackUpdatedToVersion(targetVersion), + ), + ), ); } else { _log( @@ -773,7 +783,13 @@ class _PlayerScreenState extends State { }, ); messenger.showSnackBar( - SnackBar(content: Text('Update to v$targetVersion may have failed.')), + SnackBar( + content: Text( + AppLocalizations.of( + context, + ).snackUpdateMayHaveFailed(targetVersion), + ), + ), ); } } @@ -828,7 +844,7 @@ class _PlayerScreenState extends State { ), duration: _updateSnackbarDuration, action: SnackBarAction( - label: updateActionLabel(), + label: updateActionLabel(AppLocalizations.of(context)), onPressed: () => triggerUpdateAction( context: context, info: update, @@ -911,9 +927,112 @@ class _PlayerScreenState extends State { } } + Future _openFolder({bool playNow = true}) async { + _log('folder_picker_open_requested'); + try { + final folder = await FilePicker.getDirectoryPath( + lockParentWindow: true, + initialDirectory: _settings.lastOpenDirectory, + ); + if (folder == null || folder.trim().isEmpty) { + _log('folder_picker_cancelled'); + return; + } + if (!mounted) return; + final l10n = AppLocalizations.of(context); + _settings.lastOpenDirectory = folder.trim(); + final scan = await MediaFolderScanner.scan( + folder, + maxItems: PlaylistService.maxQueueItems, + ); + if (!mounted) return; + if (scan.paths.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.snackNoSupportedMediaInFolder)), + ); + } + return; + } + _enqueue(scan.paths, playNow: playNow); + if (mounted && (scan.skipped > 0 || scan.truncated > 0)) { + final parts = []; + if (scan.skipped > 0) { + parts.add(l10n.snackFolderScanSkipped(scan.skipped)); + } + if (scan.truncated > 0) { + parts.add( + l10n.snackQueueTruncated( + PlaylistService.maxQueueItems, + scan.truncated, + ), + ); + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(parts.join(' ')))); + } + } on PlatformException catch (e) { + final detail = (e.message == null || e.message!.trim().isEmpty) + ? e.code + : e.message!.trim(); + _log( + 'folder_picker_platform_exception', + message: detail, + severity: DebugSeverity.error, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).snackFolderScanFailed(detail), + ), + ), + ); + } + } catch (e) { + _log( + 'folder_picker_failed', + message: e.toString(), + severity: DebugSeverity.error, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).snackFolderScanFailed(e.toString()), + ), + ), + ); + } + } + } + + Future _openUrl() async { + if (!_settings.experimentalFeaturesEnabled) return; + final l10n = AppLocalizations.of(context); + final url = await OpenUrlDialog.show(context); + if (!mounted) return; + final trimmed = url?.trim() ?? ''; + if (trimmed.isEmpty) return; + if (!PlayableSource.isSupportedUrl(trimmed)) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.snackInvalidStreamUrl))); + } + return; + } + await _loadSource(PlayableSource.url(trimmed)); + } + Future _loadFile(String filePath, {bool forcePlay = false}) { + return _loadSource(PlayableSource.file(filePath), forcePlay: forcePlay); + } + + Future _loadSource(PlayableSource source, {bool forcePlay = false}) { return _playback.loadQueue.enqueue( - () => _loadFileInternal(filePath, forcePlay: forcePlay), + () => _loadSourceInternal(source, forcePlay: forcePlay), onError: (Object e, StackTrace st) { _log( 'load_queue_failed', @@ -943,16 +1062,16 @@ class _PlayerScreenState extends State { await _refreshChapters(expectedCount: count); } - Future _loadFileInternal( - String filePath, { + Future _loadSourceInternal( + PlayableSource source, { bool forcePlay = false, }) async { if (_isDisposed) return; - final requestedPath = filePath.trim(); - if (requestedPath.isEmpty) { + final requestedValue = source.value.trim(); + if (requestedValue.isEmpty) { _log( - 'file_load_invalid_path', - detailsBuilder: () => {'path': filePath}, + 'media_load_invalid_source', + detailsBuilder: () => {'source': source.value}, severity: DebugSeverity.warn, ); if (mounted) { @@ -965,12 +1084,31 @@ class _PlayerScreenState extends State { return; } - final normalizedPath = await _resolveSandboxedPath(requestedPath); + if (source.isUrl && !PlayableSource.isSupportedUrl(requestedValue)) { + _log( + 'url_load_invalid', + detailsBuilder: () => {'url': requestedValue}, + severity: DebugSeverity.warn, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).snackInvalidStreamUrl), + ), + ); + } + return; + } + + final normalizedSource = source.isUrl + ? PlayableSource.url(requestedValue) + : PlayableSource.file(await _resolveSandboxedPath(requestedValue)); + final normalizedValue = normalizedSource.value; - if (!File(normalizedPath).existsSync()) { + if (normalizedSource.isFile && !File(normalizedValue).existsSync()) { _log( 'file_load_missing', - detailsBuilder: () => {'path': normalizedPath}, + detailsBuilder: () => {'path': normalizedValue}, severity: DebugSeverity.warn, ); _settings.pruneRecentFiles(); @@ -984,15 +1122,19 @@ class _PlayerScreenState extends State { return; } - final ext = p.extension(normalizedPath).toLowerCase().replaceFirst('.', ''); + final ext = normalizedSource.extension ?? ''; _log( 'file_load_started', - detailsBuilder: () => {'path': normalizedPath, 'extension': ext}, + detailsBuilder: () => { + 'source': normalizedValue, + 'source_type': normalizedSource.type.name, + 'extension': ext, + }, ); - if (!PlayerPathUtils.isSupportedExtension(ext)) { + if (normalizedSource.isFile && !PlayerPathUtils.isSupportedExtension(ext)) { _log( 'file_load_unsupported_extension', - detailsBuilder: () => {'extension': ext, 'path': normalizedPath}, + detailsBuilder: () => {'extension': ext, 'path': normalizedValue}, severity: DebugSeverity.warn, ); if (mounted) { @@ -1013,7 +1155,7 @@ class _PlayerScreenState extends State { _resumePathInProgress = null; _playback.chapterGate.invalidate(); setState(() { - _currentFile = normalizedPath; + _currentSource = normalizedSource; _currentTracks = null; _currentTrackSelection = null; _chapters = const []; @@ -1038,7 +1180,7 @@ class _PlayerScreenState extends State { await _playerService.setProperty('lavfi-complex', ''); _mixActive = false; await _playerService.open( - normalizedPath, + normalizedValue, play: forcePlay || _settings.autoPlay, ); } catch (e) { @@ -1046,13 +1188,13 @@ class _PlayerScreenState extends State { _log( permissionDenied ? 'file_load_permission_denied' : 'file_load_failed', message: e.toString(), - detailsBuilder: () => {'path': normalizedPath}, + detailsBuilder: () => {'source': normalizedValue}, severity: permissionDenied ? DebugSeverity.warn : DebugSeverity.error, ); if (!_playback.isLoadCurrent(gen)) return; if (mounted) { setState(() { - _currentFile = null; + _currentSource = null; _isAudioFile = false; _albumArtTrackId = null; }); @@ -1078,8 +1220,8 @@ class _PlayerScreenState extends State { // Load the same source into the seek preview service (no-op when the // feature is disabled or for audio-only files). - if (!_isAudioFile) { - unawaited(_seekPreviewService.setSource(normalizedPath)); + if (normalizedSource.isFile && !_isAudioFile) { + unawaited(_seekPreviewService.setSource(normalizedValue)); } else { unawaited(_seekPreviewService.setSource(null)); } @@ -1087,25 +1229,33 @@ class _PlayerScreenState extends State { _log( 'file_load_succeeded', detailsBuilder: () => { - 'path': normalizedPath, + 'source': normalizedValue, + 'source_type': normalizedSource.type.name, 'auto_play': _settings.autoPlay, }, ); try { - _settings.addRecentFile(normalizedPath); - _rememberLastOpenDirectory(normalizedPath); + final shouldPersistRecent = + normalizedSource.isFile || + PlayableSource.isDisplaySafeUrl(normalizedValue); + if (shouldPersistRecent) { + _settings.addRecentFile(normalizedValue); + } + if (normalizedSource.isFile) { + _rememberLastOpenDirectory(normalizedValue); + } _log( - 'recent_file_added', + shouldPersistRecent ? 'recent_file_added' : 'recent_url_skipped', category: DebugLogCategory.settings, - detailsBuilder: () => {'path': normalizedPath}, + detailsBuilder: () => {'source': normalizedValue}, ); } catch (e) { _log( 'recent_file_persist_failed', category: DebugLogCategory.settings, message: e.toString(), - detailsBuilder: () => {'path': normalizedPath}, + detailsBuilder: () => {'source': normalizedValue}, severity: DebugSeverity.warn, ); } @@ -1116,14 +1266,16 @@ class _PlayerScreenState extends State { // Apply post-load settings: equalizer, multi-audio mix, refresh chapters, // and publish to media session. - _resumePathInProgress = normalizedPath; + _resumePathInProgress = normalizedSource.isFile ? normalizedValue : null; unawaited(_applyEqualizer()); unawaited(_refreshChapters()); unawaited(_applyMultiAudioMix()); - unawaited(_maybeApplyResume(normalizedPath)); + if (normalizedSource.isFile) { + unawaited(_maybeApplyResume(normalizedValue)); + } unawaited( _mediaSession.updateMetadata( - title: p.basenameWithoutExtension(normalizedPath), + title: normalizedSource.displayName, duration: _duration, ), ); @@ -1221,7 +1373,13 @@ class _PlayerScreenState extends State { void _loadRecentFile(String path) { _settings.pruneRecentFiles(); _log('recent_file_open_requested', detailsBuilder: () => {'path': path}); - unawaited(_openRequestedFile(path)); + final source = PlayableSource.fromStored(path); + if (source == null) return; + if (source.isUrl) { + unawaited(_loadSource(source)); + } else { + unawaited(_openRequestedFile(source.value)); + } } Future _reopenLastFile() async { @@ -1238,7 +1396,13 @@ class _PlayerScreenState extends State { category: DebugLogCategory.ui, detailsBuilder: () => {'path': lastPath}, ); - await _openRequestedFile(lastPath); + final source = PlayableSource.fromStored(lastPath); + if (source == null) return; + if (source.isUrl) { + await _loadSource(source); + } else { + await _openRequestedFile(source.value); + } } void _rememberLastOpenDirectory(String filePath) { @@ -1831,7 +1995,7 @@ class _PlayerScreenState extends State { await _playerService.stop(); unawaited(_seekPreviewService.setSource(null)); setState(() { - _currentFile = null; + _currentSource = null; _isAudioFile = false; _hasVideoOutput = false; _hasAlbumArtTrack = false; @@ -1841,6 +2005,10 @@ class _PlayerScreenState extends State { }); }, onOpenFile: _openFile, + onOpenFolder: () => unawaited(_openFolder()), + onOpenUrl: _settings.experimentalFeaturesEnabled + ? () => unawaited(_openUrl()) + : null, onReopenLast: () { _log( 'control_reopen_last_pressed', @@ -1923,6 +2091,13 @@ class _PlayerScreenState extends State { ?.copyWith(fontWeight: FontWeight.bold), ), ), + IconButton( + icon: const Icon(Icons.info_outline), + tooltip: l10n.tooltipMediaInfo, + onPressed: _currentFile == null + ? null + : () => unawaited(_showMediaInfoDialog()), + ), if (items.isNotEmpty) IconButton( icon: const Icon(Icons.clear_all), @@ -1955,8 +2130,8 @@ class _PlayerScreenState extends State { itemCount: items.length, itemBuilder: (context, index) { final isCurrent = index == _playlist.index; - final path = items[index]; - final name = p.basename(path); + final source = items[index]; + final name = source.displayName; return Padding( padding: const EdgeInsets.symmetric( @@ -1973,7 +2148,7 @@ class _PlayerScreenState extends State { child: InkWell( onTap: () { _playlist.jumpTo(index); - unawaited(_loadFile(path)); + unawaited(_loadSource(source)); }, child: Padding( padding: const EdgeInsets.symmetric( @@ -1985,6 +2160,8 @@ class _PlayerScreenState extends State { Icon( isCurrent ? Icons.play_arrow + : source.isUrl + ? Icons.link : Icons.music_note, size: 18, color: isCurrent @@ -2040,13 +2217,33 @@ class _PlayerScreenState extends State { // Actions at bottom Padding( padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: double.infinity, - child: FilledButton.tonalIcon( - onPressed: _pickFilesToEnqueue, - icon: const Icon(Icons.add), - label: Text(l10n.dialogPlayQueueAddFiles), - ), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + FilledButton.tonalIcon( + onPressed: _pickFilesToEnqueue, + icon: const Icon(Icons.add), + label: Text(l10n.dialogPlayQueueAddFiles), + ), + FilledButton.tonalIcon( + key: const Key('queue-add-folder-button'), + onPressed: () => unawaited( + _openFolder(playNow: _playlist.isEmpty), + ), + icon: const Icon(Icons.create_new_folder), + label: Text(l10n.buttonOpenFolder), + ), + if (items.isNotEmpty) + FilledButton.tonalIcon( + key: const Key('queue-remove-missing-button'), + onPressed: () => + unawaited(_removeMissingQueueItems()), + icon: const Icon(Icons.cleaning_services), + label: Text(l10n.actionRemoveMissing), + ), + ], ), ), ], @@ -2092,6 +2289,19 @@ class _PlayerScreenState extends State { icon: const Icon(Icons.folder_open), label: Text(l10n.buttonOpenFile), ), + FilledButton.tonalIcon( + key: const Key('open-folder-empty-button'), + onPressed: () => unawaited(_openFolder()), + icon: const Icon(Icons.create_new_folder), + label: Text(l10n.buttonOpenFolder), + ), + if (_settings.experimentalFeaturesEnabled) + FilledButton.tonalIcon( + key: const Key('open-url-empty-button'), + onPressed: () => unawaited(_openUrl()), + icon: const Icon(Icons.link), + label: Text(l10n.buttonOpenUrl), + ), FilledButton.tonalIcon( key: const Key('reopen-last-empty-button'), onPressed: _reopenLastFile, @@ -2196,9 +2406,12 @@ class _PlayerScreenState extends State { } Widget _buildAudioBackground({required bool showAlbumArt}) { - final fileName = _currentFile != null - ? p.basenameWithoutExtension(_currentFile!) - : ''; + final source = _currentSource; + final fileName = source == null + ? '' + : (source.isFile + ? p.basenameWithoutExtension(source.value) + : source.displayName); final colorScheme = Theme.of(context).colorScheme; return LayoutBuilder( builder: (context, constraints) { @@ -2315,8 +2528,10 @@ class _PlayerScreenState extends State { } String get _osdTitle { - if (_currentFile == null) return ''; - return p.basenameWithoutExtension(_currentFile!); + final source = _currentSource; + if (source == null) return ''; + if (source.isFile) return p.basenameWithoutExtension(source.value); + return source.displayName; } String _stripOsdTimestamp(String? raw) { @@ -2426,7 +2641,8 @@ class _PlayerScreenState extends State { Future _takeScreenshot() async { final l10n = AppLocalizations.of(context); - if (_currentFile == null) return; + final source = _currentSource; + if (source == null) return; final fmt = _settings.screenshotFormat; final mime = fmt == 'png' ? 'image/png' : 'image/jpeg'; final bytes = await _playerService.screenshot(format: mime); @@ -2445,13 +2661,19 @@ class _PlayerScreenState extends State { severity: DebugSeverity.warn, ); } - final base = p.basenameWithoutExtension(_currentFile!); + final rawBase = source.isFile + ? p.basenameWithoutExtension(source.value) + : source.displayName; + final base = rawBase + .replaceAll(RegExp(r'[\\/:*?"<>|]+'), '_') + .trim() + .replaceAll(RegExp(r'\s+'), '_'); final ts = DateTime.now() .toIso8601String() .replaceAll(':', '-') .split('.') .first; - final outPath = p.join(dir, '${base}_$ts.$fmt'); + final outPath = p.join(dir, '${base.isEmpty ? 'dacx' : base}_$ts.$fmt'); try { await File(outPath).writeAsBytes(bytes, flush: true); _showOsdMessage(l10n.osdScreenshotSaved); @@ -2569,13 +2791,13 @@ class _PlayerScreenState extends State { } Future _reloadCurrentForMixChange() async { - final path = _currentFile; - if (path == null) return; + final source = _currentSource; + if (source == null) return; if (_mixReloadInFlight) return; _mixReloadInFlight = true; try { final savedPos = _position; - await _loadFile(path); + await _loadSource(source); if (savedPos > Duration.zero) { await Future.delayed(_mixReloadDelay); if (mounted && _position < _resumeStartThreshold) { @@ -2735,13 +2957,20 @@ class _PlayerScreenState extends State { if (next == null) return; final l10n = AppLocalizations.of(context); _showOsdMessage(delta > 0 ? l10n.osdNextInQueue : l10n.osdPreviousInQueue); - await _loadFile(next); + await _loadSource(next); } void _enqueue(List paths, {bool playNow = false}) { - if (paths.isEmpty) return; + _enqueueSources( + paths.map(PlayableSource.file).toList(growable: false), + playNow: playNow, + ); + } + + void _enqueueSources(List sources, {bool playNow = false}) { + if (sources.isEmpty) return; if (playNow || _playlist.isEmpty) { - final dropped = _playlist.replace(paths); + final dropped = _playlist.replaceSources(sources); if (dropped > 0 && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -2754,15 +2983,15 @@ class _PlayerScreenState extends State { ); } final first = _playlist.current; - if (first != null) unawaited(_loadFile(first)); + if (first != null) unawaited(_loadSource(first)); } else { - final dropped = _playlist.addAll(paths); + final dropped = _playlist.addAllSources(sources); _showOsdMessage( - paths.length == 1 + sources.length == 1 ? AppLocalizations.of(context).osdAddedToQueue : AppLocalizations.of( context, - ).osdAddedMultipleToQueue(paths.length), + ).osdAddedMultipleToQueue(sources.length), ); if (dropped > 0 && mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -2778,6 +3007,18 @@ class _PlayerScreenState extends State { } } + Future _removeMissingQueueItems() async { + final removed = await _playlist.removeMissingFiles(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).snackQueueRemovedMissing(removed), + ), + ), + ); + } + // ── Compact / mini-player mode ──────────────────────────── Future _toggleCompactMode() async { @@ -2826,6 +3067,96 @@ class _PlayerScreenState extends State { } } + // ── Media info ──────────────────────────────────────────── + + Future _showMediaInfoDialog() async { + final source = _currentSource; + if (source == null) return; + if (_chapters.isEmpty) { + await _refreshChapters(); + } + if (!mounted) return; + final l10n = AppLocalizations.of(context); + final width = await _playerService.getProperty('width'); + final height = await _playerService.getProperty('height'); + if (!mounted) return; + final tracks = _currentTracks; + final audioTracks = + tracks?.audio.where((t) => t.id != 'auto' && t.id != 'no').length ?? 0; + final subtitleTracks = + tracks?.subtitle.where((t) => t.id != 'auto' && t.id != 'no').length ?? + 0; + final audioSelection = _currentTrackSelection?.audio; + final subtitleSelection = _currentTrackSelection?.subtitle; + final resolution = + (width != null && + height != null && + width.trim().isNotEmpty && + height.trim().isNotEmpty) + ? '${width.trim()} × ${height.trim()}' + : l10n.mediaInfoUnknown; + final type = source.isUrl + ? l10n.mediaInfoTypeUrlStream + : (_isAudioFile + ? l10n.mediaInfoTypeAudioFile + : l10n.mediaInfoTypeVideoFile); + + await showDialog( + context: context, + builder: (ctx) => MediaInfoDialog( + width: _dialogWidth(ctx, 520), + fields: [ + MediaInfoField( + label: l10n.mediaInfoSource, + value: source.isUrl + ? PlayableSource.displaySafeUrl(source.value) + : source.value, + ), + MediaInfoField(label: l10n.mediaInfoType, value: type), + MediaInfoField( + label: l10n.mediaInfoDuration, + value: _duration.inMilliseconds > 0 + ? _formatDuration(_duration) + : l10n.mediaInfoUnknown, + ), + MediaInfoField(label: l10n.mediaInfoResolution, value: resolution), + MediaInfoField( + label: l10n.mediaInfoAudioTracks, + value: '$audioTracks', + ), + MediaInfoField( + label: l10n.mediaInfoSubtitleTracks, + value: '$subtitleTracks', + ), + MediaInfoField( + label: l10n.mediaInfoChapters, + value: '${_chapters.length}', + ), + MediaInfoField( + label: l10n.mediaInfoAudioSelection, + value: audioSelection == null + ? l10n.mediaInfoUnknown + : _trackLabel( + audioSelection.title, + audioSelection.language, + audioSelection.id, + ), + ), + MediaInfoField( + label: l10n.mediaInfoSubtitleSelection, + value: subtitleSelection == null || subtitleSelection.id == 'no' + ? l10n.mediaInfoUnknown + : _trackLabel( + subtitleSelection.title, + subtitleSelection.language, + subtitleSelection.id, + ), + ), + ], + ), + ); + } + // ── More menu ───────────────────────────────────────────── Future _showMoreMenu() async { @@ -2997,7 +3328,8 @@ class _PlayerScreenState extends State { }()); }, ), - if (!_isAudioFile) + if (!_isAudioFile && + (_currentSource?.isFile ?? false)) switchItem( icon: Icons.image_search, label: l10n.menuSeekThumbnailsBeta, @@ -3007,9 +3339,10 @@ class _PlayerScreenState extends State { setSheetState(() {}); unawaited( _seekPreviewService.setEnabled(v).then((_) { - if (v && _currentFile != null) { + final source = _currentSource; + if (v && source != null && source.isFile) { return _seekPreviewService.setSource( - _currentFile, + source.value, ); } return null; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2b7946a..3583fec 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -463,7 +463,7 @@ class _SettingsScreenState extends State { children: AccentColor.values.map((ac) { final isSelected = ac == _s.accentColor; return Semantics( - label: 'Accent color ${ac.name}', + label: l10n.semanticsAccentColor(ac.name), button: true, selected: isSelected, child: GestureDetector( @@ -794,9 +794,11 @@ class _SettingsScreenState extends State { ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Dacx v${update.version} is available!'), + content: Text( + AppLocalizations.of(context).snackUpdateAvailable(update.version), + ), action: SnackBarAction( - label: updateActionLabel(), + label: updateActionLabel(AppLocalizations.of(context)), onPressed: () => triggerUpdateAction( context: context, info: update, diff --git a/lib/services/debug_log_service.dart b/lib/services/debug_log_service.dart index ef35123..3fdc111 100644 --- a/lib/services/debug_log_service.dart +++ b/lib/services/debug_log_service.dart @@ -2,6 +2,8 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; +import '../models/playable_source.dart'; + enum DebugLogCategory { playback, settings, update, hwaccel, ui, system, error } enum DebugSeverity { info, warn, error } @@ -108,12 +110,15 @@ class DebugLogService extends ChangeNotifier { if (_entries.isEmpty) return 'No debug log entries.'; final lines = []; for (final entry in _entries) { - lines.add(_formatEntry(entry, redactSensitive: redactSensitive)); + lines.add(formatEntry(entry, redactSensitive: redactSensitive)); } return lines.join('\n'); } - String _formatEntry(DebugLogEntry entry, {required bool redactSensitive}) { + static String formatEntry( + DebugLogEntry entry, { + bool redactSensitive = true, + }) { final ts = entry.timestamp.toIso8601String(); final sev = entry.severity.name.toUpperCase(); final cat = entry.category.name.toUpperCase(); @@ -140,25 +145,35 @@ class DebugLogService extends ChangeNotifier { return buf.toString(); } - String? _sanitizeDetailValue(String key, Object? value) { + static String? _sanitizeDetailValue(String key, Object? value) { final text = value?.toString(); if (text == null) return null; if (_isSensitiveKey(key)) return ''; final normalized = text.replaceAll('\n', r'\n'); + if (PlayableSource.isSupportedUrl(normalized)) { + return PlayableSource.displaySafeUrl(normalized); + } if (_isPathLikeKey(key)) { return _redactPath(normalized); } return _sanitizeText(normalized); } - String? _sanitizeText(String? value) { + static String? _sanitizeText(String? value) { if (value == null) return null; final trimmed = value.trim(); if (trimmed.isEmpty) return trimmed; + if (PlayableSource.isSupportedUrl(trimmed)) { + return PlayableSource.displaySafeUrl(trimmed); + } if (_looksLikePath(trimmed)) { return _redactPath(trimmed); } return value + .replaceAllMapped(_urlPattern, (match) { + final candidate = match.group(0) ?? ''; + return PlayableSource.displaySafeUrl(candidate); + }) .replaceAllMapped(_pathPattern, (match) { final prefix = match.group(1) ?? ''; final candidate = match.group(2) ?? ''; @@ -167,7 +182,7 @@ class DebugLogService extends ChangeNotifier { .replaceAll('\n', r'\n'); } - bool _isPathLikeKey(String key) { + static bool _isPathLikeKey(String key) { final normalized = key.toLowerCase(); if (normalized == 'url' || normalized.endsWith('_url') || @@ -180,7 +195,7 @@ class DebugLogService extends ChangeNotifier { normalized.contains('cwd'); } - bool _isSensitiveKey(String key) { + static bool _isSensitiveKey(String key) { final normalized = key.toLowerCase(); return normalized.contains('token') || normalized.contains('secret') || @@ -195,13 +210,13 @@ class DebugLogService extends ChangeNotifier { normalized.contains('session'); } - bool _looksLikePath(String value) { + static bool _looksLikePath(String value) { return value.startsWith('/') || value.startsWith(r'\\') || RegExp(r'^[A-Za-z]:[\\/]').hasMatch(value); } - String _redactPath(String value) { + static String _redactPath(String value) { final normalized = value.replaceAll('\\', '/'); final segments = normalized .split('/') @@ -214,4 +229,9 @@ class DebugLogService extends ChangeNotifier { r"""(^|[\s(="'])((?:[A-Za-z]:[\\/]|\\\\)[^\s,|;"')]+|/(?!/)[^\s,|;"')]+(?:/[^\s,|;"')]+)*)""", multiLine: true, ); + + static final RegExp _urlPattern = RegExp( + r"""https?://[^\s,|;"')<>\]]+""", + caseSensitive: false, + ); } diff --git a/lib/services/playlist_service.dart b/lib/services/playlist_service.dart index 876f158..aec68ef 100644 --- a/lib/services/playlist_service.dart +++ b/lib/services/playlist_service.dart @@ -1,25 +1,30 @@ import 'dart:math'; +import 'dart:io'; +import 'dart:isolate'; import 'package:flutter/foundation.dart'; +import '../models/playable_source.dart'; + /// In-memory playback queue. Persistence is intentionally omitted: queue lives /// for the session. The `index` is `-1` when the queue is empty. class PlaylistService extends ChangeNotifier { /// Maximum tracks kept in memory for one session (bulk folder drops). static const int maxQueueItems = 1000; - final List _items = []; + final List _items = []; int _index = -1; bool _shuffle = false; final List _shuffleOrder = []; int _shufflePos = -1; + bool _disposed = false; - List get items => List.unmodifiable(_items); + List get items => List.unmodifiable(_items); int get index => _index; int get length => _items.length; bool get isEmpty => _items.isEmpty; bool get isNotEmpty => _items.isNotEmpty; - String? get current => + PlayableSource? get current => (_index >= 0 && _index < _items.length) ? _items[_index] : null; bool get shuffle => _shuffle; bool get hasNext => _peekRelative(1) != null; @@ -35,7 +40,18 @@ class PlaylistService extends ChangeNotifier { /// Replaces the queue and starts at [startIndex] (default 0). /// Returns how many paths were dropped because of [maxQueueItems]. int replace(List paths, {int startIndex = 0}) { - final filtered = paths.where((p) => p.trim().isNotEmpty).toList(); + return replaceSources( + paths.map(PlayableSource.file).toList(growable: false), + startIndex: startIndex, + ); + } + + /// Replaces the queue with typed media sources. + /// Returns how many sources were dropped because of [maxQueueItems]. + int replaceSources(List sources, {int startIndex = 0}) { + final filtered = sources + .where((source) => source.value.trim().isNotEmpty) + .toList(); final capped = filtered.length > maxQueueItems ? filtered.sublist(0, maxQueueItems) : filtered; @@ -56,7 +72,17 @@ class PlaylistService extends ChangeNotifier { /// Appends [paths] to the queue. /// Returns how many paths were dropped because of [maxQueueItems]. int addAll(List paths) { - final filtered = paths.where((p) => p.trim().isNotEmpty).toList(); + return addAllSources( + paths.map(PlayableSource.file).toList(growable: false), + ); + } + + /// Appends typed media sources to the queue. + /// Returns how many sources were dropped because of [maxQueueItems]. + int addAllSources(List sources) { + final filtered = sources + .where((source) => source.value.trim().isNotEmpty) + .toList(); if (filtered.isEmpty) return 0; final room = maxQueueItems - _items.length; if (room <= 0) return filtered.length; @@ -71,14 +97,15 @@ class PlaylistService extends ChangeNotifier { } /// Inserts [path] right after the current item. - void playNext(String path) { - final trimmed = path.trim(); - if (trimmed.isEmpty) return; + void playNext(String path) => playNextSource(PlayableSource.file(path)); + + void playNextSource(PlayableSource source) { + if (source.value.trim().isEmpty) return; if (_items.isEmpty) { - _items.add(trimmed); + _items.add(source); _index = 0; } else { - _items.insert(_index + 1, trimmed); + _items.insert(_index + 1, source); } if (_shuffle) _rebuildShuffleOrder(preserveCurrent: true); notifyListeners(); @@ -98,6 +125,46 @@ class PlaylistService extends ChangeNotifier { notifyListeners(); } + Future removeMissingFiles() async { + if (_items.isEmpty || _disposed) return 0; + final checkedPaths = _items + .where((source) => source.isFile) + .map((source) => source.value) + .toSet(); + if (checkedPaths.isEmpty) return 0; + final pathsToCheck = checkedPaths.toList(growable: false); + final existingPaths = (await Isolate.run( + () => _existingFilePaths(pathsToCheck), + )).toSet(); + if (_disposed) return 0; + final missingPaths = checkedPaths.difference(existingPaths); + if (missingPaths.isEmpty) return 0; + final current = this.current; + final before = _items.length; + _items.removeWhere( + (source) => source.isFile && missingPaths.contains(source.value), + ); + final removed = before - _items.length; + if (removed == 0) return 0; + if (_items.isEmpty) { + _index = -1; + } else if (current != null) { + final nextIndex = _items.indexOf(current); + _index = nextIndex >= 0 ? nextIndex : _index.clamp(0, _items.length - 1); + } else { + _index = _index.clamp(0, _items.length - 1); + } + if (_shuffle) _rebuildShuffleOrder(preserveCurrent: true); + notifyListeners(); + return removed; + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + void clear() { if (_items.isEmpty) return; _items.clear(); @@ -119,7 +186,7 @@ class PlaylistService extends ChangeNotifier { /// Advances by [delta] in queue order. Returns the new path or null when out /// of bounds. Honors shuffle. - String? advance(int delta) { + PlayableSource? advance(int delta) { if (_items.isEmpty) return null; if (_shuffle) { if (_shuffleOrder.isEmpty) return null; @@ -137,7 +204,7 @@ class PlaylistService extends ChangeNotifier { return _items[_index]; } - String? _peekRelative(int delta) { + PlayableSource? _peekRelative(int delta) { if (_items.isEmpty) return null; if (_shuffle) { if (_shuffleOrder.isEmpty) return null; @@ -173,3 +240,15 @@ class PlaylistService extends ChangeNotifier { _shufflePos = preserveCurrent ? 0 : -1; } } + +List _existingFilePaths(List paths) { + final existing = []; + for (final path in paths) { + try { + if (File(path).existsSync()) existing.add(path); + } catch (_) { + // Treat inaccessible paths as missing. + } + } + return existing; +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 508f0ce..352f4af 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/playable_source.dart'; import '../models/update_channel.dart'; import 'instance_mode_service.dart'; @@ -402,6 +403,9 @@ class SettingsService extends ChangeNotifier { bool _isSafeFilePath(String value) { if (value.isEmpty) return false; if (value.contains('\u0000')) return false; + if (PlayableSource.isSupportedUrl(value)) { + return PlayableSource.isDisplaySafeUrl(value); + } final segments = value.replaceAll('\\', '/').split('/'); if (segments.any((s) => s == '..')) return false; return true; @@ -938,6 +942,9 @@ class SettingsService extends ChangeNotifier { } bool _recentFilePathExists(String path) { + if (PlayableSource.isSupportedUrl(path)) { + return PlayableSource.isDisplaySafeUrl(path); + } try { return File(path).existsSync(); } catch (e) { diff --git a/lib/widgets/compact_exit_button.dart b/lib/widgets/compact_exit_button.dart index aa46856..308260c 100644 --- a/lib/widgets/compact_exit_button.dart +++ b/lib/widgets/compact_exit_button.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; + const _animFastDuration = Duration(milliseconds: 140); class CompactExitButton extends StatefulWidget { @@ -16,11 +18,12 @@ class _CompactExitButtonState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); return Tooltip( - message: 'Exit mini-player', + message: l10n.tooltipExitMiniPlayer, child: Semantics( button: true, - label: 'Exit mini-player', + label: l10n.tooltipExitMiniPlayer, child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovering = true), diff --git a/lib/widgets/debug_log_panel.dart b/lib/widgets/debug_log_panel.dart index 81ce317..bc9091e 100644 --- a/lib/widgets/debug_log_panel.dart +++ b/lib/widgets/debug_log_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../l10n/app_localizations.dart'; import '../services/debug_log_service.dart'; class DebugLogPanel extends StatelessWidget { @@ -11,6 +12,7 @@ class DebugLogPanel extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.fromLTRB(10, 6, 10, 6), child: DecoratedBox( @@ -32,14 +34,14 @@ class DebugLogPanel extends StatelessWidget { children: [ Row( children: [ - const Expanded( + Expanded( child: Text( - 'Debug Log', - style: TextStyle(fontWeight: FontWeight.w600), + l10n.debugLogTitle, + style: const TextStyle(fontWeight: FontWeight.w600), ), ), Text( - '${debugLog.entryCount} entries', + l10n.debugLogEntryCount(debugLog.entryCount), style: Theme.of(context).textTheme.bodySmall, ), ], @@ -50,7 +52,7 @@ class DebugLogPanel extends StatelessWidget { TextButton.icon( onPressed: () => _copyDebugLog(context), icon: const Icon(Icons.copy_all_outlined, size: 18), - label: const Text('Copy Log'), + label: Text(l10n.debugLogCopyButton), ), const SizedBox(width: 8), TextButton.icon( @@ -58,15 +60,15 @@ class DebugLogPanel extends StatelessWidget { ? () => _clearDebugLog(context) : null, icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Clear Log'), + label: Text(l10n.debugLogClearButton), ), ], ), const SizedBox(height: 8), if (entries.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 10), - child: Text('No debug events yet.'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Text(l10n.debugLogEmpty), ) else SizedBox( @@ -107,43 +109,22 @@ class DebugLogPanel extends StatelessWidget { details: {'entry_count': debugLog.entryCount}, ); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Redacted debug log copied to clipboard.')), - ); + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.snackDebugLogCopied))); } void _clearDebugLog(BuildContext context) { debugLog.clear(); if (!context.mounted) return; + final l10n = AppLocalizations.of(context); ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('Debug log cleared.'))); + ).showSnackBar(SnackBar(content: Text(l10n.snackDebugLogCleared))); } static String renderDebugEntry(DebugLogEntry entry) { - final detailsText = _renderDebugDetails(entry.details); - final base = - '[${entry.timestamp.toIso8601String()}] ' - '[${entry.severity.name.toUpperCase()}] ' - '[${entry.category.name}] ' - '${entry.event}'; - final msg = entry.message?.trim(); - if (msg != null && msg.isNotEmpty && detailsText.isNotEmpty) { - return '$base - $msg | $detailsText'; - } - if (msg != null && msg.isNotEmpty) return '$base - $msg'; - if (detailsText.isNotEmpty) return '$base | $detailsText'; - return base; - } - - static String _renderDebugDetails(Map details) { - if (details.isEmpty) return ''; - final keys = details.keys.toList()..sort(); - return keys - .map((key) { - final safe = details[key]?.toString().replaceAll('\n', r'\n'); - return '$key=$safe'; - }) - .join(', '); + return DebugLogService.formatEntry(entry); } } diff --git a/lib/widgets/media_info_dialog.dart b/lib/widgets/media_info_dialog.dart new file mode 100644 index 0000000..7811621 --- /dev/null +++ b/lib/widgets/media_info_dialog.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; + +class MediaInfoField { + const MediaInfoField({required this.label, required this.value}); + + final String label; + final String value; +} + +class MediaInfoDialog extends StatelessWidget { + const MediaInfoDialog({super.key, required this.fields, this.width = 520}); + + final List fields; + final double width; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return AlertDialog( + title: Text(l10n.dialogMediaInfoTitle), + content: SizedBox( + width: width, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final field in fields) + _MediaInfoRow(label: field.label, value: field.value), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.actionClose), + ), + ], + ); + } +} + +class _MediaInfoRow extends StatelessWidget { + const _MediaInfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 132, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/open_url_dialog.dart b/lib/widgets/open_url_dialog.dart new file mode 100644 index 0000000..4eb4e2a --- /dev/null +++ b/lib/widgets/open_url_dialog.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/playable_source.dart'; + +class OpenUrlDialog extends StatefulWidget { + const OpenUrlDialog({super.key, this.initialValue = ''}); + + final String initialValue; + + static Future show( + BuildContext context, { + String initialValue = '', + }) { + return showDialog( + context: context, + builder: (_) => OpenUrlDialog(initialValue: initialValue), + ); + } + + @override + State createState() => _OpenUrlDialogState(); +} + +class _OpenUrlDialogState extends State { + late final TextEditingController _controller = TextEditingController( + text: widget.initialValue, + ); + String? _errorText; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final trimmed = _controller.text.trim(); + if (trimmed.isEmpty) { + Navigator.of(context).pop(); + return; + } + if (!PlayableSource.isSupportedUrl(trimmed)) { + setState(() { + _errorText = AppLocalizations.of(context).snackInvalidStreamUrl; + }); + return; + } + Navigator.of(context).pop(trimmed); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return AlertDialog( + title: Text(l10n.dialogOpenUrlTitle), + content: TextField( + key: const Key('open-url-text-field'), + controller: _controller, + autofocus: true, + decoration: InputDecoration( + hintText: l10n.dialogOpenUrlHint, + errorText: _errorText, + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + onChanged: (_) { + if (_errorText == null) return; + setState(() => _errorText = null); + }, + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.actionCancel), + ), + FilledButton(onPressed: _submit, child: Text(l10n.actionOpen)), + ], + ); + } +} diff --git a/lib/widgets/seek_slider.dart b/lib/widgets/seek_slider.dart index 29816c9..c08141f 100644 --- a/lib/widgets/seek_slider.dart +++ b/lib/widgets/seek_slider.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../services/seek_preview_service.dart'; class SeekSliderWithHover extends StatefulWidget { @@ -64,7 +65,7 @@ class _SeekSliderWithHoverState extends State { return MergeSemantics( child: Semantics( slider: true, - label: 'Seek bar', + label: AppLocalizations.of(context).semanticsSeekBar, value: '$positionLabel of $durationLabel', child: LayoutBuilder( builder: (context, constraints) { diff --git a/lib/widgets/transport_controls.dart b/lib/widgets/transport_controls.dart index be609e5..586c4e4 100644 --- a/lib/widgets/transport_controls.dart +++ b/lib/widgets/transport_controls.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import '../l10n/app_localizations.dart'; +import '../models/playable_source.dart'; import '../services/settings_service.dart'; class TransportControls extends StatelessWidget { @@ -14,6 +15,8 @@ class TransportControls extends StatelessWidget { final VoidCallback onPlayPause; final VoidCallback onStop; final VoidCallback onOpenFile; + final VoidCallback? onOpenFolder; + final VoidCallback? onOpenUrl; final VoidCallback onReopenLast; final ValueChanged onVolumeChanged; final ValueChanged onLoopModeChanged; @@ -35,6 +38,8 @@ class TransportControls extends StatelessWidget { required this.onPlayPause, required this.onStop, required this.onOpenFile, + this.onOpenFolder, + this.onOpenUrl, required this.onReopenLast, required this.onVolumeChanged, required this.onLoopModeChanged, @@ -99,7 +104,7 @@ class TransportControls extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.skip_previous), - tooltip: 'Previous Track (PageUp)', + tooltip: l10n.tooltipPreviousTrack, onPressed: hasMedia ? onPrevious : null, iconSize: 22, ), @@ -135,7 +140,7 @@ class TransportControls extends StatelessWidget { ), IconButton( icon: const Icon(Icons.skip_next), - tooltip: 'Next Track (PageDown)', + tooltip: l10n.tooltipNextTrack, onPressed: hasMedia ? onNext : null, iconSize: 22, ), @@ -219,7 +224,7 @@ class TransportControls extends StatelessWidget { // Queue toggle button IconButton( icon: const Icon(Icons.queue_music), - tooltip: 'Play Queue', + tooltip: l10n.tooltipPlayQueue, iconSize: 20, onPressed: onToggleQueue, ), @@ -259,6 +264,20 @@ class TransportControls extends StatelessWidget { tooltip: l10n.tooltipOpenFile, onPressed: onOpenFile, ), + if (onOpenFolder != null) + IconButton( + key: const Key('open-folder-transport-button'), + icon: const Icon(Icons.create_new_folder), + tooltip: l10n.tooltipOpenFolder, + onPressed: onOpenFolder, + ), + if (onOpenUrl != null) + IconButton( + key: const Key('open-url-transport-button'), + icon: const Icon(Icons.link), + tooltip: l10n.tooltipOpenUrl, + onPressed: onOpenUrl, + ), if (recents.isNotEmpty) PopupMenuButton( tooltip: l10n.tooltipRecentFiles, @@ -266,7 +285,8 @@ class TransportControls extends StatelessWidget { icon: const Icon(Icons.arrow_drop_down), onSelected: onRecentFileSelected, itemBuilder: (context) => recents.map((path) { - final name = p.basename(path).trim(); + final source = PlayableSource.fromStored(path); + final name = source?.displayName ?? p.basename(path).trim(); return PopupMenuItem( key: ValueKey(path), value: path, diff --git a/lib/widgets/update_progress_dialog.dart b/lib/widgets/update_progress_dialog.dart index ff794ff..6ea1ac2 100644 --- a/lib/widgets/update_progress_dialog.dart +++ b/lib/widgets/update_progress_dialog.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../services/debug_log_service.dart'; import '../services/self_update_service.dart'; import '../services/update_service.dart'; @@ -43,8 +44,10 @@ Future triggerUpdateAction({ } /// Returns the right snackbar action label for the current platform. -String updateActionLabel() => - SelfUpdateService.isSupported() ? 'Install' : 'View'; +String updateActionLabel(AppLocalizations l10n) => + SelfUpdateService.isSupported() + ? l10n.updateActionInstall + : l10n.updateActionView; class UpdateProgressDialog extends StatefulWidget { const UpdateProgressDialog({ @@ -94,37 +97,26 @@ class _UpdateProgressDialogState extends State { String _formatMb(int bytes) => '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; - String _outcomeLabel(SelfUpdateOutcome o) { - switch (o) { - case SelfUpdateOutcome.unsupportedPlatform: - return 'Self-update is not supported on this platform.'; - case SelfUpdateOutcome.missingAsset: - return 'The release does not include an installer for this platform.'; - case SelfUpdateOutcome.missingChecksums: - return 'The release does not include a checksums file. Cannot verify download.'; - case SelfUpdateOutcome.missingSignature: - return 'The release does not include a signed update manifest. Cannot verify update authenticity.'; - case SelfUpdateOutcome.downloadFailed: - return 'Download failed.'; - case SelfUpdateOutcome.checksumMismatch: - return 'Downloaded file failed checksum verification. Refusing to install.'; - case SelfUpdateOutcome.extractionFailed: - return 'Could not extract the update package.'; - case SelfUpdateOutcome.signatureInvalid: - return 'Downloaded app failed code-signature verification.'; - case SelfUpdateOutcome.bundleIdentifierMismatch: - return 'Downloaded app has an unexpected bundle identifier. Refusing to install.'; - case SelfUpdateOutcome.versionMismatch: - return 'Downloaded app version does not match the selected update. Refusing to install.'; - case SelfUpdateOutcome.teamIdMismatch: - return 'Downloaded app is signed by an unexpected developer. Refusing to install.'; - case SelfUpdateOutcome.gatekeeperRejected: - return 'Self-update is not available on this build (missing signing configuration).'; - case SelfUpdateOutcome.spawnFailed: - return 'Could not launch the installer.'; - case SelfUpdateOutcome.spawned: - return 'Update started.'; - } + String _outcomeLabel(AppLocalizations l10n, SelfUpdateOutcome o) { + return switch (o) { + SelfUpdateOutcome.unsupportedPlatform => + l10n.updateOutcomeUnsupportedPlatform, + SelfUpdateOutcome.missingAsset => l10n.updateOutcomeMissingAsset, + SelfUpdateOutcome.missingChecksums => l10n.updateOutcomeMissingChecksums, + SelfUpdateOutcome.missingSignature => l10n.updateOutcomeMissingSignature, + SelfUpdateOutcome.downloadFailed => l10n.updateOutcomeDownloadFailed, + SelfUpdateOutcome.checksumMismatch => l10n.updateOutcomeChecksumMismatch, + SelfUpdateOutcome.extractionFailed => l10n.updateOutcomeExtractionFailed, + SelfUpdateOutcome.signatureInvalid => l10n.updateOutcomeSignatureInvalid, + SelfUpdateOutcome.bundleIdentifierMismatch => + l10n.updateOutcomeBundleIdMismatch, + SelfUpdateOutcome.versionMismatch => l10n.updateOutcomeVersionMismatch, + SelfUpdateOutcome.teamIdMismatch => l10n.updateOutcomeTeamIdMismatch, + SelfUpdateOutcome.gatekeeperRejected => + l10n.updateOutcomeGatekeeperRejected, + SelfUpdateOutcome.spawnFailed => l10n.updateOutcomeSpawnFailed, + SelfUpdateOutcome.spawned => l10n.updateOutcomeStarted, + }; } @override @@ -135,23 +127,27 @@ class _UpdateProgressDialogState extends State { } Widget _buildProgressDialog(BuildContext context) { + final l10n = AppLocalizations.of(context); final progress = _progress; final fraction = progress?.fraction; final downloaded = progress?.downloadedBytes ?? 0; final total = progress?.totalBytes; final showVerifying = fraction == 1.0; final statusText = Platform.isMacOS && progress == null - ? 'Downloading and verifying in the update helper...' + ? l10n.updateDialogDownloadingVerifying : showVerifying - ? 'Verifying signature...' + ? l10n.updateDialogVerifyingSignature : total != null - ? 'Downloading ${_formatMb(downloaded)} / ${_formatMb(total)}' - : 'Downloading...'; + ? l10n.updateDialogDownloadingProgress( + _formatMb(downloaded), + _formatMb(total), + ) + : l10n.updateDialogDownloading; return PopScope( canPop: false, child: AlertDialog( - title: Text('Installing Dacx ${widget.info.version}'), + title: Text(l10n.updateDialogInstallingTitle(widget.info.version)), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -161,7 +157,7 @@ class _UpdateProgressDialogState extends State { Text(statusText, style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 4), Text( - 'Dacx will close to apply the update.', + l10n.updateDialogWillClose, style: Theme.of(context).textTheme.bodySmall, ), ], @@ -171,13 +167,14 @@ class _UpdateProgressDialogState extends State { } Widget _buildErrorDialog(BuildContext context, SelfUpdateResult result) { + final l10n = AppLocalizations.of(context); return AlertDialog( - title: const Text('Update failed'), + title: Text(l10n.updateDialogFailedTitle), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_outcomeLabel(result.outcome)), + Text(_outcomeLabel(l10n, result.outcome)), if (result.message != null && result.message!.isNotEmpty) ...[ const SizedBox(height: 8), Text(result.message!, style: Theme.of(context).textTheme.bodySmall), @@ -190,11 +187,11 @@ class _UpdateProgressDialogState extends State { widget.onFallbackToBrowser(); Navigator.of(context).pop(result); }, - child: const Text('Open release page'), + child: Text(l10n.updateDialogOpenReleasePage), ), TextButton( onPressed: () => Navigator.of(context).pop(result), - child: const Text('Close'), + child: Text(l10n.actionClose), ), ], ); diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 63260fb..99ccda4 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,7 +8,7 @@ com.apple.security.network.client - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope @@ -16,6 +16,8 @@ com.apple.security.assets.movies.read-only + com.apple.security.assets.pictures.read-write + com.apple.security.files.downloads.read-only diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 363642c..63e5832 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,7 +6,7 @@ com.apple.security.network.client - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope @@ -14,6 +14,8 @@ com.apple.security.assets.movies.read-only + com.apple.security.assets.pictures.read-write + com.apple.security.files.downloads.read-only diff --git a/pubspec.lock b/pubspec.lock index dd487a3..eae429d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -433,10 +433,10 @@ packages: dependency: transitive description: name: safe_local_storage - sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + sha256: "7483b3d5e8976f0bd263647c03b96131ee8e43f48b56fa8a8ec459e8515d74b0" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" screen_retriever: dependency: transitive description: diff --git a/test/models/playable_source_test.dart b/test/models/playable_source_test.dart new file mode 100644 index 0000000..cc929bd --- /dev/null +++ b/test/models/playable_source_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dacx/models/playable_source.dart'; + +void main() { + group('PlayableSource', () { + test('detects supported http and https URLs', () { + expect( + PlayableSource.isSupportedUrl('https://example.com/live.m3u8'), + isTrue, + ); + expect( + PlayableSource.isSupportedUrl('http://example.com/radio.mp3'), + isTrue, + ); + expect( + PlayableSource.isSupportedUrl('ftp://example.com/file.mp3'), + isFalse, + ); + expect(PlayableSource.isSupportedUrl('/tmp/file.mp3'), isFalse); + }); + + test('fromStored restores URLs and files', () { + final url = PlayableSource.fromStored('https://example.com/live.m3u8'); + final file = PlayableSource.fromStored('/tmp/song.flac'); + + expect(url?.isUrl, isTrue); + expect(url?.displayName, 'live.m3u8'); + expect(file?.isFile, isTrue); + expect(file?.displayName, 'song.flac'); + }); + + test('detects and redacts non-display-safe URL parts', () { + const signed = + 'https://user:pass@example.com/live.m3u8?token=secret#fragment'; + + expect( + PlayableSource.isDisplaySafeUrl('https://example.com/live.m3u8'), + isTrue, + ); + expect(PlayableSource.isDisplaySafeUrl(signed), isFalse); + expect( + PlayableSource.displaySafeUrl(signed), + 'https://example.com/live.m3u8?#', + ); + }); + + test('extension getter extracts from files and URLs', () { + expect(PlayableSource.file('/tmp/song.flac').extension, 'flac'); + expect(PlayableSource.file('/tmp/noext').extension, ''); + expect( + PlayableSource.url('https://example.com/stream.m3u8').extension, + 'm3u8', + ); + expect( + PlayableSource.url('https://example.com/stream.mp3?token=x').extension, + 'mp3', + ); + }); + + test('displayName falls back to host when URL has no path segments', () { + final source = PlayableSource.url('https://radio.example.com'); + expect(source.displayName, 'radio.example.com'); + }); + + test('equality and hashCode', () { + final a = PlayableSource.file('/tmp/a.mp3'); + final b = PlayableSource.file('/tmp/a.mp3'); + final c = PlayableSource.url('https://example.com/a.mp3'); + + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + }); + + test('toString returns raw value', () { + expect(PlayableSource.file('/tmp/a.mp3').toString(), '/tmp/a.mp3'); + expect( + PlayableSource.url('https://example.com/x').toString(), + 'https://example.com/x', + ); + }); + }); +} diff --git a/test/playback/media_folder_scanner_test.dart b/test/playback/media_folder_scanner_test.dart new file mode 100644 index 0000000..6a1886c --- /dev/null +++ b/test/playback/media_folder_scanner_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dacx/playback/media_folder_scanner.dart'; + +void main() { + group('MediaFolderScanner', () { + test('recursively keeps supported media and sorts paths', () async { + final dir = Directory.systemTemp.createTempSync('dacx_scan_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory('${dir.path}/nested').createSync(); + File('${dir.path}/z.mp3').writeAsStringSync('z'); + File('${dir.path}/nested/a.mkv').writeAsStringSync('a'); + File('${dir.path}/notes.txt').writeAsStringSync('skip'); + + final result = await MediaFolderScanner.scan(dir.path); + + expect(result.skipped, 1); + expect(result.truncated, 0); + expect( + result.paths.map((path) => path.replaceFirst('${dir.path}/', '')), + ['nested/a.mkv', 'z.mp3'], + ); + }); + + test('caps results and reports truncation', () async { + final dir = Directory.systemTemp.createTempSync('dacx_scan_cap_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + for (var i = 0; i < 5; i++) { + File('${dir.path}/$i.mp3').writeAsStringSync('$i'); + } + + final result = await MediaFolderScanner.scan(dir.path, maxItems: 3); + + expect(result.paths.length, 3); + expect(result.truncated, 2); + expect( + result.paths.map((path) => path.replaceFirst('${dir.path}/', '')), + ['0.mp3', '1.mp3', '2.mp3'], + ); + }); + + test('returns empty result when folder is missing', () async { + final dir = Directory.systemTemp.createTempSync('dacx_scan_missing_'); + final missingPath = '${dir.path}/gone'; + addTearDown(() => dir.deleteSync(recursive: true)); + + final result = await MediaFolderScanner.scan(missingPath); + + expect(result.paths, isEmpty); + expect(result.skipped, 0); + expect(result.truncated, 0); + }); + + test('sorts paths with natural/numeric ordering', () async { + final dir = Directory.systemTemp.createTempSync('dacx_scan_nat_sort_'); + addTearDown(() => dir.deleteSync(recursive: true)); + // Create files with unpadded track numbers. + for (final name in [ + 'track10.mp3', + 'track2.mp3', + 'track1.mp3', + 'track20.mp3', + ]) { + File('${dir.path}/$name').writeAsStringSync(name); + } + + final result = await MediaFolderScanner.scan(dir.path); + + expect( + result.paths.map((path) => path.replaceFirst('${dir.path}/', '')), + ['track1.mp3', 'track2.mp3', 'track10.mp3', 'track20.mp3'], + ); + }); + }); +} diff --git a/test/services/debug_log_service_test.dart b/test/services/debug_log_service_test.dart index 5495532..c88b9e7 100644 --- a/test/services/debug_log_service_test.dart +++ b/test/services/debug_log_service_test.dart @@ -155,5 +155,29 @@ void main() { expect(output, contains('')); expect(output, isNot(contains('/home/burnt/Videos/movie.mp4'))); }); + + test('exportText redacts URL credentials, query, and fragment', () { + final service = DebugLogService(isEnabled: () => true); + + service.log( + category: DebugLogCategory.playback, + event: 'stream_open', + message: + 'Opening https://user:pass@example.com/live.m3u8?token=secret#frag', + details: { + 'source': 'https://user:pass@example.com/live.m3u8?token=secret#frag', + }, + ); + + final output = service.exportText(); + + expect( + output, + contains('https://example.com/live.m3u8?#'), + ); + expect(output, isNot(contains('user:pass'))); + expect(output, isNot(contains('token=secret'))); + expect(output, isNot(contains('#frag'))); + }); }); } diff --git a/test/services/playlist_service_test.dart b/test/services/playlist_service_test.dart index 44db64f..6c98e92 100644 --- a/test/services/playlist_service_test.dart +++ b/test/services/playlist_service_test.dart @@ -1,7 +1,15 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:dacx/models/playable_source.dart'; import 'package:dacx/services/playlist_service.dart'; +List _values(PlaylistService service) => + service.items.map((source) => source.value).toList(growable: false); + +String? _currentValue(PlaylistService service) => service.current?.value; + void main() { group('PlaylistService basic queue ops', () { test('starts empty', () { @@ -18,13 +26,13 @@ void main() { s.replace(['a', 'b', 'c'], startIndex: 1); expect(s.length, 3); expect(s.index, 1); - expect(s.current, 'b'); + expect(_currentValue(s), 'b'); }); test('replace filters empty/whitespace entries', () { final s = PlaylistService(); s.replace(['a', '', ' ', 'b']); - expect(s.items, ['a', 'b']); + expect(_values(s), ['a', 'b']); }); test('replace clamps startIndex into bounds', () { @@ -57,14 +65,14 @@ void main() { final s = PlaylistService(); s.replace(['a', 'b', 'c'], startIndex: 0); s.playNext('x'); - expect(s.items, ['a', 'x', 'b', 'c']); + expect(_values(s), ['a', 'x', 'b', 'c']); expect(s.index, 0); }); test('playNext on empty queue starts playback', () { final s = PlaylistService(); s.playNext('only'); - expect(s.items, ['only']); + expect(_values(s), ['only']); expect(s.index, 0); }); @@ -72,16 +80,16 @@ void main() { final s = PlaylistService(); s.replace(['a', 'b', 'c'], startIndex: 2); s.removeAt(0); - expect(s.items, ['b', 'c']); + expect(_values(s), ['b', 'c']); expect(s.index, 1); - expect(s.current, 'c'); + expect(_currentValue(s), 'c'); }); test('removeAt of last current clamps to new last', () { final s = PlaylistService(); s.replace(['a', 'b', 'c'], startIndex: 2); s.removeAt(2); - expect(s.items, ['a', 'b']); + expect(_values(s), ['a', 'b']); expect(s.index, 1); }); @@ -96,9 +104,9 @@ void main() { test('advance honors bounds and updates index', () { final s = PlaylistService(); s.replace(['a', 'b', 'c'], startIndex: 0); - expect(s.advance(1), 'b'); + expect(s.advance(1)?.value, 'b'); expect(s.index, 1); - expect(s.advance(-1), 'a'); + expect(s.advance(-1)?.value, 'a'); expect(s.advance(-1), isNull); expect(s.index, 0); s.replace(['a', 'b'], startIndex: 1); @@ -131,7 +139,7 @@ void main() { final s = PlaylistService(); s.replace(List.generate(20, (i) => 'item$i'), startIndex: 7); s.setShuffle(true); - expect(s.current, 'item7'); + expect(_currentValue(s), 'item7'); expect(s.hasPrevious, isFalse); }); @@ -140,11 +148,11 @@ void main() { final items = List.generate(10, (i) => 'i$i'); s.replace(items, startIndex: 0); s.setShuffle(true); - final visited = {s.current!}; + final visited = {s.current!.value}; for (var k = 0; k < items.length - 1; k++) { final next = s.advance(1); expect(next, isNotNull); - visited.add(next!); + visited.add(next!.value); } expect(visited.length, items.length); expect(s.advance(1), isNull); @@ -155,7 +163,7 @@ void main() { s.replace(['a', 'b', 'c']); s.setShuffle(true); s.setShuffle(false); - expect(s.advance(1), 'b'); + expect(s.advance(1)?.value, 'b'); }); test('notifies listeners on mutation', () { @@ -168,5 +176,34 @@ void main() { s.clear(); expect(fired, greaterThanOrEqualTo(4)); }); + + test('supports mixed file and URL sources', () { + final s = PlaylistService(); + s.replaceSources([ + PlayableSource.file('/tmp/local.mp3'), + PlayableSource.url('https://example.com/live.m3u8'), + ]); + + expect(s.current?.isFile, isTrue); + expect(s.advance(1)?.isUrl, isTrue); + expect(_values(s), ['/tmp/local.mp3', 'https://example.com/live.m3u8']); + }); + + test('removeMissingFiles drops missing files and keeps URLs', () async { + final dir = Directory.systemTemp.createTempSync('dacx_playlist_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final existing = File('${dir.path}/keep.mp3')..writeAsStringSync('x'); + final missing = '${dir.path}/missing.mp3'; + final s = PlaylistService(); + s.replaceSources([ + PlayableSource.file(existing.path), + PlayableSource.file(missing), + PlayableSource.url('https://example.com/live.m3u8'), + ]); + + expect(await s.removeMissingFiles(), 1); + + expect(_values(s), [existing.path, 'https://example.com/live.m3u8']); + }); }); } diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index e9dcf25..c316a4e 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -65,6 +65,63 @@ void main() { expect(prefs.getString('recent_files'), jsonEncode([existing.path])); }); + test('pruneRecentFiles keeps valid stream URLs', () async { + final tempDir = await Directory.systemTemp.createTemp('dacx-settings-'); + addTearDown(() => tempDir.delete(recursive: true)); + final missing = '${tempDir.path}/gone.flac'; + const url = 'https://example.com/live.m3u8'; + + SharedPreferences.setMockInitialValues({ + 'recent_files': jsonEncode([url, missing]), + }); + final prefs = await SharedPreferences.getInstance(); + final service = SettingsService(prefs); + + final changed = service.pruneRecentFiles(notifyListeners: false); + + expect(changed, isTrue); + expect(service.recentFiles, [url]); + expect(prefs.getString('recent_files'), jsonEncode([url])); + }); + + test('addRecentFile accepts valid stream URLs', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = SettingsService(prefs); + + service.addRecentFile('https://example.com/live.m3u8'); + + expect(service.recentFiles, ['https://example.com/live.m3u8']); + }); + + test('addRecentFile skips tokenized stream URLs', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = SettingsService(prefs); + + service.addRecentFile('https://example.com/live.m3u8?token=secret'); + + expect(service.recentFiles, isEmpty); + expect(prefs.getString('recent_files'), isNull); + }); + + test('pruneRecentFiles drops tokenized stream URLs', () async { + const safeUrl = 'https://example.com/live.m3u8'; + const tokenizedUrl = 'https://example.com/live.m3u8?token=secret'; + + SharedPreferences.setMockInitialValues({ + 'recent_files': jsonEncode([tokenizedUrl, safeUrl]), + }); + final prefs = await SharedPreferences.getInstance(); + final service = SettingsService(prefs); + + final changed = service.pruneRecentFiles(notifyListeners: false); + + expect(changed, isTrue); + expect(service.recentFiles, [safeUrl]); + expect(prefs.getString('recent_files'), jsonEncode([safeUrl])); + }); + test('addRecentFile works when existing storage list is present', () async { final tempDir = await Directory.systemTemp.createTemp('dacx-settings-'); addTearDown(() => tempDir.delete(recursive: true)); diff --git a/test/widgets/media_info_dialog_test.dart b/test/widgets/media_info_dialog_test.dart new file mode 100644 index 0000000..ccfdaef --- /dev/null +++ b/test/widgets/media_info_dialog_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dacx/l10n/app_localizations.dart'; +import 'package:dacx/widgets/media_info_dialog.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); +} + +void main() { + group('MediaInfoDialog', () { + testWidgets('renders media fields and closes cleanly', (tester) async { + await tester.pumpWidget( + _wrap( + Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (_) => const MediaInfoDialog( + fields: [ + MediaInfoField( + label: 'Source', + value: 'https://example.com/live.m3u8', + ), + MediaInfoField(label: 'Type', value: 'URL stream'), + MediaInfoField(label: 'Duration', value: 'Unknown'), + ], + ), + ); + }, + child: const Text('Launch'), + ), + ), + ), + ); + + await tester.tap(find.text('Launch')); + await tester.pumpAndSettle(); + + expect(find.text('Media info'), findsOneWidget); + expect(find.text('Source'), findsOneWidget); + expect(find.text('https://example.com/live.m3u8'), findsOneWidget); + expect(find.text('Type'), findsOneWidget); + expect(find.text('URL stream'), findsOneWidget); + expect(find.text('Duration'), findsOneWidget); + expect(find.text('Unknown'), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Media info'), findsNothing); + }); + }); +} diff --git a/test/widgets/open_url_dialog_test.dart b/test/widgets/open_url_dialog_test.dart new file mode 100644 index 0000000..8c8f24d --- /dev/null +++ b/test/widgets/open_url_dialog_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dacx/l10n/app_localizations.dart'; +import 'package:dacx/widgets/open_url_dialog.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); +} + +void main() { + group('OpenUrlDialog', () { + testWidgets('shows validation error for unsupported URLs', (tester) async { + String? result; + await tester.pumpWidget( + _wrap( + Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await OpenUrlDialog.show(context); + }, + child: const Text('Launch'), + ), + ), + ), + ); + + await tester.tap(find.text('Launch')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('open-url-text-field')), + 'ftp://example.com/stream.m3u8', + ); + await tester.tap(find.text('Open')); + await tester.pump(); + + expect( + find.text('Enter a valid http:// or https:// URL.'), + findsOneWidget, + ); + expect(result, isNull); + }); + + testWidgets('returns trimmed URL after a valid submission', (tester) async { + String? result; + await tester.pumpWidget( + _wrap( + Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await OpenUrlDialog.show(context); + }, + child: const Text('Launch'), + ), + ), + ), + ); + + await tester.tap(find.text('Launch')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('open-url-text-field')), + ' https://example.com/live.m3u8 ', + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(result, 'https://example.com/live.m3u8'); + expect(find.text('Open URL'), findsNothing); + }); + }); +} diff --git a/test/widgets/seek_slider_test.dart b/test/widgets/seek_slider_test.dart index d7b57c9..ad89e81 100644 --- a/test/widgets/seek_slider_test.dart +++ b/test/widgets/seek_slider_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:dacx/l10n/app_localizations.dart'; import 'package:dacx/services/seek_preview_service.dart'; import 'package:dacx/widgets/seek_slider.dart'; @@ -99,6 +100,8 @@ Widget _harness({ Duration duration = const Duration(seconds: 60), }) { return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: Padding( padding: const EdgeInsets.all(40), diff --git a/test/widgets/transport_controls_test.dart b/test/widgets/transport_controls_test.dart index f63cbf4..5e4e516 100644 --- a/test/widgets/transport_controls_test.dart +++ b/test/widgets/transport_controls_test.dart @@ -105,5 +105,72 @@ void main() { expect(find.byTooltip('Recent files'), findsOneWidget); }, ); + + testWidgets('folder button appears when callback is provided', ( + tester, + ) async { + var folderPressed = false; + await tester.pumpWidget( + _wrap( + TransportControls( + isPlaying: false, + volume: 50, + hasMedia: false, + speed: 1.0, + loopMode: LoopMode.none, + recentFiles: const [], + onPlayPause: () {}, + onStop: () {}, + onOpenFile: () {}, + onOpenFolder: () => folderPressed = true, + onReopenLast: () {}, + onVolumeChanged: (_) {}, + onLoopModeChanged: (_) {}, + onRecentFileSelected: (_) {}, + onSettingsPressed: () {}, + ), + ), + ); + + final button = find.byKey(const Key('open-folder-transport-button')); + expect(button, findsOneWidget); + await tester.tap(button); + expect(folderPressed, isTrue); + }); + + testWidgets('url button is hidden unless callback is provided', ( + tester, + ) async { + Widget build({VoidCallback? onOpenUrl}) { + return _wrap( + TransportControls( + isPlaying: false, + volume: 50, + hasMedia: false, + speed: 1.0, + loopMode: LoopMode.none, + recentFiles: const [], + onPlayPause: () {}, + onStop: () {}, + onOpenFile: () {}, + onOpenUrl: onOpenUrl, + onReopenLast: () {}, + onVolumeChanged: (_) {}, + onLoopModeChanged: (_) {}, + onRecentFileSelected: (_) {}, + onSettingsPressed: () {}, + ), + ); + } + + await tester.pumpWidget(build()); + expect(find.byKey(const Key('open-url-transport-button')), findsNothing); + + await tester.pumpWidget(build(onOpenUrl: () {})); + expect( + find.byKey(const Key('open-url-transport-button')), + findsOneWidget, + ); + }); }); } diff --git a/test/widgets/update_progress_dialog_test.dart b/test/widgets/update_progress_dialog_test.dart index 7522336..0c9de16 100644 --- a/test/widgets/update_progress_dialog_test.dart +++ b/test/widgets/update_progress_dialog_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:dacx/l10n/app_localizations.dart'; import 'package:dacx/services/self_update_service.dart'; import 'package:dacx/services/update_service.dart'; import 'package:dacx/widgets/update_progress_dialog.dart'; @@ -32,7 +33,11 @@ class _FakeSelfUpdateService extends SelfUpdateService { } Widget _wrap(Widget child) { - return MaterialApp(home: Scaffold(body: child)); + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); } void main() { @@ -193,11 +198,21 @@ void main() { }); group('updateActionLabel', () { - test('matches self-update platform support', () { - expect( - updateActionLabel(), - SelfUpdateService.isSupported() ? 'Install' : 'View', + testWidgets('matches self-update platform support', (tester) async { + late String label; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + label = updateActionLabel(AppLocalizations.of(context)); + return const SizedBox.shrink(); + }, + ), + ), ); + expect(label, SelfUpdateService.isSupported() ? 'Install' : 'View'); }); });