diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index f3c93676..4cb53238 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -15,6 +15,9 @@ import Gobackend // Import Go framework private var libraryScanProgressEventSink: FlutterEventSink? private var lastLibraryScanProgressPayload: String? + /// Currently accessed security-scoped URL for library folder + private var activeSecurityScopedURL: URL? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -915,6 +918,26 @@ import Gobackend // Import Go framework let response = GobackendReadAudioMetadataJSON(filePath, &error) if let error = error { throw error } return response + + // iOS Security-Scoped Bookmark for Local Library + case "resolveIosBookmark": + let args = call.arguments as! [String: Any] + let bookmarkBase64 = args["bookmark"] as! String + return try resolveIosBookmark(bookmarkBase64) + + case "startAccessingIosBookmark": + let args = call.arguments as! [String: Any] + let bookmarkBase64 = args["bookmark"] as! String + return try startAccessingIosBookmark(bookmarkBase64) + + case "stopAccessingIosBookmark": + stopAccessingIosBookmark() + return nil + + case "createIosBookmarkFromPath": + let args = call.arguments as! [String: Any] + let path = args["path"] as! String + return try createIosBookmarkFromPath(path) // Lyrics Provider Settings case "setLyricsProviders": @@ -954,6 +977,112 @@ import Gobackend // Import Go framework ) } } + + // MARK: - iOS Security-Scoped Bookmark Helpers + + /// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker). + /// The path must currently be accessible (within the same picker session). + /// Returns base64-encoded bookmark data. + private func createIosBookmarkFromPath(_ path: String) throws -> String { + let url = URL(fileURLWithPath: path) + do { + let bookmarkData = try url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + return bookmarkData.base64EncodedString() + } catch { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"] + ) + } + } + + /// Resolve a base64-encoded security-scoped bookmark and return the resolved path. + /// Does NOT start accessing the resource. + private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String { + guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"] + ) + } + + var isStale = false + let url: URL + do { + url = try URL( + resolvingBookmarkData: bookmarkData, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"] + ) + } + + return url.path + } + + /// Resolve a base64-encoded bookmark, start accessing the security-scoped resource, + /// and return the resolved filesystem path. The resource stays accessed until + /// `stopAccessingIosBookmark()` is called. + private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String { + // Stop any previously accessed resource first + stopAccessingIosBookmark() + + guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"] + ) + } + + var isStale = false + let url: URL + do { + url = try URL( + resolvingBookmarkData: bookmarkData, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"] + ) + } + + guard url.startAccessingSecurityScopedResource() else { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"] + ) + } + + activeSecurityScopedURL = url + return url.path + } + + /// Stop accessing the currently active security-scoped resource, if any. + private func stopAccessingIosBookmark() { + if let url = activeSecurityScopedURL { + url.stopAccessingSecurityScopedResource() + activeSecurityScopedURL = nil + } + } } private final class ClosureStreamHandler: NSObject, FlutterStreamHandler { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index d42b9fa6..8501a4e3 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -57,6 +57,8 @@ class AppSettings { final bool localLibraryEnabled; // Enable local library scanning final String localLibraryPath; // Path to scan for audio files + final String + localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks @@ -122,6 +124,7 @@ class AppSettings { this.songLinkRegion = 'US', this.localLibraryEnabled = false, this.localLibraryPath = '', + this.localLibraryBookmark = '', this.localLibraryShowDuplicates = true, this.hasCompletedTutorial = false, this.lyricsProviders = const [ @@ -185,6 +188,7 @@ class AppSettings { String? songLinkRegion, bool? localLibraryEnabled, String? localLibraryPath, + String? localLibraryBookmark, bool? localLibraryShowDuplicates, bool? hasCompletedTutorial, List? lyricsProviders, @@ -249,6 +253,7 @@ class AppSettings { songLinkRegion: songLinkRegion ?? this.songLinkRegion, localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, + localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 933178e3..cc47ca8e 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', + localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '', localLibraryShowDuplicates: json['localLibraryShowDuplicates'] as bool? ?? true, hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false, @@ -128,6 +129,7 @@ Map _$AppSettingsToJson( 'songLinkRegion': instance.songLinkRegion, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, + 'localLibraryBookmark': instance.localLibraryBookmark, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, 'hasCompletedTutorial': instance.hasCompletedTutorial, 'lyricsProviders': instance.lyricsProviders, diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index afa25f66..fe9f21ad 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -272,6 +272,7 @@ class LocalLibraryNotifier extends Notifier { Future startScan( String folderPath, { bool forceFullScan = false, + String? iosBookmark, }) async { if (state.isScanning) { _log.w('Scan already in progress'); @@ -316,8 +317,27 @@ class LocalLibraryNotifier extends Notifier { _startProgressPolling(); + // On iOS, start accessing the security-scoped bookmark so the Go backend + // can read files outside the app sandbox. + String? resolvedPath; + bool didStartSecurityAccess = false; + if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { + resolvedPath = + await PlatformBridge.startAccessingIosBookmark(iosBookmark); + if (resolvedPath != null) { + didStartSecurityAccess = true; + _log.i('Started iOS security-scoped access: $resolvedPath'); + } else { + _log.w( + 'Failed to start iOS security-scoped access, ' + 'falling back to original path', + ); + } + } + final effectiveFolderPath = resolvedPath ?? folderPath; + try { - final isSaf = folderPath.startsWith('content://'); + final isSaf = effectiveFolderPath.startsWith('content://'); // Get all file paths from download history to exclude them. // Merge DB + in-memory state to avoid race when a fresh download has not @@ -344,8 +364,8 @@ class LocalLibraryNotifier extends Notifier { if (forceFullScan) { // Full scan path - ignores existing data final results = isSaf - ? await PlatformBridge.scanSafTree(folderPath) - : await PlatformBridge.scanLibraryFolder(folderPath); + ? await PlatformBridge.scanSafTree(effectiveFolderPath) + : await PlatformBridge.scanLibraryFolder(effectiveFolderPath); if (_scanCancelRequested) { state = state.copyWith(isScanning: false, scanWasCancelled: true); await _showScanCancelledNotification(); @@ -424,12 +444,12 @@ class LocalLibraryNotifier extends Notifier { final Map result; if (isSaf) { result = await PlatformBridge.scanSafTreeIncremental( - folderPath, + effectiveFolderPath, existingFiles, ); } else { result = await PlatformBridge.scanLibraryFolderIncremental( - folderPath, + effectiveFolderPath, existingFiles, ); } @@ -553,6 +573,10 @@ class LocalLibraryNotifier extends Notifier { state = state.copyWith(isScanning: false, scanWasCancelled: false); await _showScanFailedNotification(e.toString()); } finally { + if (didStartSecurityAccess) { + await PlatformBridge.stopAccessingIosBookmark(); + _log.i('Stopped iOS security-scoped access'); + } _stopProgressPolling(); } } @@ -807,12 +831,26 @@ class LocalLibraryNotifier extends Notifier { return decoded; } - Future cleanupMissingFiles() async { - final removed = await _db.cleanupMissingFiles(); - if (removed > 0) { - await reloadFromStorage(); + Future cleanupMissingFiles({String? iosBookmark}) async { + bool didStartSecurityAccess = false; + if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { + final resolved = + await PlatformBridge.startAccessingIosBookmark(iosBookmark); + if (resolved != null) { + didStartSecurityAccess = true; + } + } + try { + final removed = await _db.cleanupMissingFiles(); + if (removed > 0) { + await reloadFromStorage(); + } + return removed; + } finally { + if (didStartSecurityAccess) { + await PlatformBridge.stopAccessingIosBookmark(); + } } - return removed; } Future clearLibrary() async { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 133bdeec..a7858319 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -532,6 +532,19 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setLocalLibraryBookmark(String bookmark) { + state = state.copyWith(localLibraryBookmark: bookmark); + _saveSettings(); + } + + void setLocalLibraryPathAndBookmark(String path, String bookmark) { + state = state.copyWith( + localLibraryPath: path, + localLibraryBookmark: bookmark, + ); + _saveSettings(); + } + void setLocalLibraryShowDuplicates(bool show) { state = state.copyWith(localLibraryShowDuplicates: show); _saveSettings(); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index cd8a4c7b..79eca05b 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -487,6 +487,7 @@ class _LocalAlbumScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -726,6 +727,7 @@ class _LocalAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( + tooltip: 'Play track', onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( @@ -950,13 +952,18 @@ class _LocalAlbumScreenState extends ConsumerState { return; } - final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + final settings = ref.read(settingsProvider); + final localLibraryPath = settings.localLibraryPath.trim(); + final iosBookmark = settings.localLibraryBookmark; try { if (localLibraryPath.isNotEmpty && !ref.read(localLibraryProvider).isScanning) { await ref .read(localLibraryProvider.notifier) - .startScan(localLibraryPath); + .startScan( + localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } else { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -1447,6 +1454,9 @@ class _LocalAlbumScreenState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 9299320c..1aa3bad3 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1064,6 +1064,9 @@ class _QueueTabState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitPlaylistSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -3068,37 +3071,41 @@ class _QueueTabState extends ConsumerState { ), ); - return GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: cover, + return Semantics( + button: true, + label: 'Open $title, $count ${count == 1 ? 'item' : 'items'}', + child: GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: cover, + ), ), - ), - const SizedBox(height: 6), - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), - ), - Text( - '$count ${count == 1 ? 'item' : 'items'}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 6), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), - ), - ], + Text( + '$count ${count == 1 ? 'item' : 'items'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ); } @@ -3913,112 +3920,117 @@ class _QueueTabState extends ConsumerState { final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( album.sampleFilePath, ); - return GestureDetector( - onTap: () => _navigateToDownloadedAlbum(album), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, + return Semantics( + button: true, + label: + 'Open album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', + child: GestureDetector( + onTap: () => _navigateToDownloadedAlbum(album), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), ), ), + ) + : album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 300, + memCacheHeight: 300, + cacheManager: CoverCacheManager.instance, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, ), - ) - : album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - memCacheWidth: 300, - memCacheHeight: 300, - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, ), ), - ), - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 12, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Text( - '${album.tracks.length}', - style: TextStyle( + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.music_note, + size: 12, color: colorScheme.onPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.bold, ), - ), - ], + const SizedBox(width: 4), + Text( + '${album.tracks.length}', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 8), - Text( - album.albumName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ClickableArtistName( - artistName: album.artistName, - coverUrl: album.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + Text( + album.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - ), - ], + ClickableArtistName( + artistName: album.artistName, + coverUrl: album.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ); } @@ -4029,102 +4041,107 @@ class _QueueTabState extends ConsumerState { _GroupedLocalAlbum album, ColorScheme colorScheme, ) { - return GestureDetector( - onTap: () => _navigateToLocalAlbum(album), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: album.coverPath != null - ? Image.file( - File(album.coverPath!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, + return Semantics( + button: true, + label: + 'Open local album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', + child: GestureDetector( + onTap: () => _navigateToLocalAlbum(album), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: album.coverPath != null + ? Image.file( + File(album.coverPath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), ), ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, ), ), - ), - ), - // "Local" badge instead of track count - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder, - size: 12, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - '${album.tracks.length}', - style: TextStyle( + ), + // "Local" badge instead of track count + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder, + size: 12, color: colorScheme.onTertiaryContainer, - fontSize: 12, - fontWeight: FontWeight.bold, ), - ), - ], + const SizedBox(width: 4), + Text( + '${album.tracks.length}', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 8), - Text( - album.albumName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ClickableArtistName( - artistName: album.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + Text( + album.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - ), - ], + ClickableArtistName( + artistName: album.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ); } @@ -4356,13 +4373,18 @@ class _QueueTabState extends ConsumerState { return; } - final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + final settings = ref.read(settingsProvider); + final localLibraryPath = settings.localLibraryPath.trim(); + final iosBookmark = settings.localLibraryBookmark; try { if (localLibraryPath.isNotEmpty && !ref.read(localLibraryProvider).isScanning) { await ref .read(localLibraryProvider.notifier) - .startScan(localLibraryPath); + .startScan( + localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } else { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -4996,6 +5018,9 @@ class _QueueTabState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -5275,18 +5300,27 @@ class _QueueTabState extends ConsumerState { ), ); case DownloadStatus.finalizing: - return SizedBox( - width: 40, - height: 40, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.tertiary, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], + return Semantics( + label: 'Finalizing download', + child: SizedBox( + width: 40, + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + ), + ExcludeSemantics( + child: Icon( + Icons.edit_note, + color: colorScheme.tertiary, + size: 16, + ), + ), + ], + ), ), ); case DownloadStatus.completed: @@ -5314,18 +5348,32 @@ class _QueueTabState extends ConsumerState { ), ) else - Icon(Icons.error_outline, color: colorScheme.error, size: 20), - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, + Semantics( + label: 'Downloaded file missing', + child: ExcludeSemantics( + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ), ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: 20, + const SizedBox(width: 4), + Semantics( + label: 'Download completed', + child: ExcludeSemantics( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: 20, + ), + ), ), ), ], @@ -5888,27 +5936,34 @@ class _QueueTabState extends ConsumerState { valueListenable: fileExistsListenable, builder: (context, fileExists, child) { return fileExists - ? GestureDetector( - onTap: () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: - item.coverUrl ?? - item.localCoverPath ?? - '', - ), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, + ? Semantics( + button: true, + label: + 'Play ${item.trackName} by ${item.artistName}', + child: GestureDetector( + onTap: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? + item.localCoverPath ?? + '', ), - child: Icon( - Icons.play_arrow, - color: colorScheme.onPrimary, - size: 16, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: ExcludeSemantics( + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + size: 16, + ), + ), ), ), ) diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index c01c685f..edb3eed3 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -311,9 +312,12 @@ class _CacheManagementPageState extends ConsumerState { final orphanedDownloads = await ref .read(downloadHistoryProvider.notifier) .cleanupOrphanedDownloads(); + final iosBookmark = ref.read(settingsProvider).localLibraryBookmark; final missingLibraryEntries = await ref .read(localLibraryProvider.notifier) - .cleanupMissingFiles(); + .cleanupMissingFiles( + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -384,11 +388,13 @@ class _CacheManagementPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), actions: [ IconButton( + tooltip: 'Refresh', onPressed: _isBusy ? null : _refreshOverview, icon: const Icon(Icons.refresh), ), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 2d91f4a1..e71c1046 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -132,7 +132,23 @@ class _LibrarySettingsPageState extends ConsumerState { // Fallback for older devices final result = await FilePicker.platform.getDirectoryPath(); if (result != null) { - ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + if (Platform.isIOS) { + // On iOS, create a security-scoped bookmark so we can access + // this folder across app restarts and from the Go backend. + final bookmark = + await PlatformBridge.createIosBookmarkFromPath(result); + if (bookmark != null && bookmark.isNotEmpty) { + ref + .read(settingsProvider.notifier) + .setLocalLibraryPathAndBookmark(result, bookmark); + } else { + // Bookmark creation failed; save path anyway (works for + // app-internal folders like Documents/). + ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + } + } else { + ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + } } } } @@ -140,6 +156,7 @@ class _LibrarySettingsPageState extends ConsumerState { Future _startScan({bool forceFullScan = false}) async { final settings = ref.read(settingsProvider); final libraryPath = settings.localLibraryPath; + final iosBookmark = settings.localLibraryBookmark; if (libraryPath.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -148,7 +165,14 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - if (!libraryPath.startsWith('content://') && + // On iOS with a bookmark, try resolving the bookmark first to validate + // access instead of checking the path directly (which may fail outside + // the app sandbox). + if (Platform.isIOS && iosBookmark.isNotEmpty) { + // Bookmark will be resolved inside startScan; skip Directory.exists + // check since security-scoped paths are not accessible without the + // bookmark being activated. + } else if (!libraryPath.startsWith('content://') && !await Directory(libraryPath).exists()) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -158,9 +182,11 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - await ref - .read(localLibraryProvider.notifier) - .startScan(libraryPath, forceFullScan: forceFullScan); + await ref.read(localLibraryProvider.notifier).startScan( + libraryPath, + forceFullScan: forceFullScan, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } Future _cancelScan() async { @@ -200,9 +226,12 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _cleanupMissingFiles() async { + final iosBookmark = ref.read(settingsProvider).localLibraryBookmark; final removed = await ref .read(localLibraryProvider.notifier) - .cleanupMissingFiles(); + .cleanupMissingFiles( + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -230,6 +259,7 @@ class _LibrarySettingsPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 840958ab..86ae6d0d 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1155,6 +1155,47 @@ class PlatformBridge { await _channel.invokeMethod('cancelLibraryScan'); } + // MARK: - iOS Security-Scoped Bookmark + + /// Create a security-scoped bookmark from a filesystem path picked by + /// FilePicker on iOS. Must be called while the picker session is still active. + /// Returns base64-encoded bookmark data, or null on failure. + static Future createIosBookmarkFromPath(String path) async { + try { + final result = await _channel.invokeMethod('createIosBookmarkFromPath', { + 'path': path, + }); + return result as String?; + } catch (e) { + _log.w('Failed to create iOS bookmark from path: $e'); + return null; + } + } + + /// Resolve a base64-encoded iOS security-scoped bookmark and start accessing + /// the resource. Returns the resolved filesystem path. + /// The resource stays accessed until [stopAccessingIosBookmark] is called. + static Future startAccessingIosBookmark(String bookmark) async { + try { + final result = await _channel.invokeMethod('startAccessingIosBookmark', { + 'bookmark': bookmark, + }); + return result as String?; + } catch (e) { + _log.w('Failed to start accessing iOS bookmark: $e'); + return null; + } + } + + /// Stop accessing the currently active iOS security-scoped resource. + static Future stopAccessingIosBookmark() async { + try { + await _channel.invokeMethod('stopAccessingIosBookmark'); + } catch (e) { + _log.w('Failed to stop accessing iOS bookmark: $e'); + } + } + /// Read metadata from a single audio file static Future?> readAudioMetadata( String filePath,