mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-20 07:04:49 +02:00
fix(ios): local library scan fails on iOS due to missing security-scoped bookmark access
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<String>? 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,
|
||||
|
||||
@@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
|
||||
'songLinkRegion': instance.songLinkRegion,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
'lyricsProviders': instance.lyricsProviders,
|
||||
|
||||
@@ -272,6 +272,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
Future<void> 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<LocalLibraryState> {
|
||||
|
||||
_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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
final Map<String, dynamic> 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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final removed = await _db.cleanupMissingFiles();
|
||||
if (removed > 0) {
|
||||
await reloadFromStorage();
|
||||
Future<int> 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<void> clearLibrary() async {
|
||||
|
||||
@@ -532,6 +532,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_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();
|
||||
|
||||
@@ -487,6 +487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -726,6 +727,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: _exitSelectionMode,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
|
||||
+309
-254
@@ -1064,6 +1064,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
),
|
||||
);
|
||||
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
_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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
),
|
||||
);
|
||||
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<QueueTab> {
|
||||
),
|
||||
)
|
||||
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<QueueTab> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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<CacheManagementPage> {
|
||||
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<CacheManagementPage> {
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -132,7 +132,23 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
// 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<LibrarySettingsPage> {
|
||||
Future<void> _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<LibrarySettingsPage> {
|
||||
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<LibrarySettingsPage> {
|
||||
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<void> _cancelScan() async {
|
||||
@@ -200,9 +226,12 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _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<LibrarySettingsPage> {
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
|
||||
@@ -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<String?> 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<String?> 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<void> 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<Map<String, dynamic>?> readAudioMetadata(
|
||||
String filePath,
|
||||
|
||||
Reference in New Issue
Block a user