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');
});
});