Skip to main content

Overview

Aradia can import audio from YouTube videos and playlists, making them available as audiobooks. This feature is perfect for lectures, podcasts, or any long-form audio content on YouTube.

Import Features

Single Videos

Import individual YouTube videos as standalone audiobooks

Playlists

Import entire playlists with all videos as chapters

WebView Browser

Built-in YouTube browser for easy navigation

Metadata Extraction

Automatic extraction of title, author, and description

YouTube WebView

The app includes a built-in YouTube browser powered by flutter_inappwebview:
InAppWebView(
  keepAlive: Provider.of<WebViewKeepAliveProvider>(context, listen: false)
      .keepAlive,
  initialUrlRequest: URLRequest(
    url: WebUri('https://www.youtube.com')
  ),
  onWebViewCreated: (controller) async {
    _webViewController = controller;
    final url = await controller.getUrl();
    if (url != null && url.toString() != 'about:blank') {
      if (mounted) {
        setState(() {
          _currentUrl = url.toString();
          _isWebViewLoading = false;
          _checkIfCurrentContentIsImported();
        });
      }
    }
  },
  // ... URL tracking handlers
)

URL Tracking

The WebView tracks navigation to detect when a user is on a video or playlist page:
onLoadStop: (controller, url) {
  if (mounted) {
    setState(() {
      _isWebViewLoading = false;
      _currentUrl = url.toString();
      _checkIfCurrentContentIsImported();
    });
  }
},
onUpdateVisitedHistory: (controller, url, androidIsReload) {
  if (mounted && url != null) {
    setState(() {
      _currentUrl = url.toString();
      _checkIfCurrentContentIsImported();
    });
  }
}
The import button only appears when viewing a YouTube video or playlist page.

Import Process

1

Navigate to Content

Browse YouTube and navigate to a video or playlist:
URL Validation (youtube_webview.dart:96-101)
if (!_currentUrl!.contains('youtube.com/watch') &&
    !_currentUrl!.contains('youtube.com/playlist')) {
  setState(() => _errorMessageYT =
      'Please navigate to a YouTube video or playlist page');
  return;
}
2

Extract Metadata

The app extracts video/playlist information using youtube_explode_dart:
Video Import (youtube_webview.dart:152-175)
final video = await yt.videos.get(_currentUrl!);
entityId = video.id.value;
entityTitle = video.title;
entityAuthor = video.author;
entityDescription = video.description;
tags = _extractTags(video.description);
coverImage = video.thumbnails.highResUrl;

files.add(AudiobookFile.fromMap({
  "identifier": video.id.value,
  "title": video.title,
  "name": "${video.id.value}.mp3",
  "track": 1,
  "size": 0,
  "length": video.duration?.inSeconds.toDouble() ?? 0.0,
  "url": video.url,
  "highQCoverImage": video.thumbnails.highResUrl,
}));
3

Extract Tags

Hashtags are automatically extracted from descriptions:
Tag Extraction (youtube_webview.dart:204-214)
List<String> _extractTags(String description) {
  final tags = <String>{};
  final words = description.split(RegExp(r'\s+'));
  for (final word in words) {
    if (word.startsWith('#') && word.length > 1) {
      final cleaned = word.substring(1)
          .replaceAll(RegExp(r'[^\w-]'), '');
      if (cleaned.isNotEmpty) tags.add(cleaned);
    }
  }
  return tags.toList();
}
4

Create Audiobook

Build the audiobook object with extracted metadata:
Audiobook Creation (youtube_webview.dart:177-191)
final audiobook = Audiobook.fromMap({
  "title": entityTitle,
  "id": entityId,
  "description": entityDescription,
  "author": entityAuthor,
  "date": DateTime.now().toIso8601String(),
  "downloads": 0,
  "subject": tags,
  "size": 0,
  "rating": 0.0,
  "reviews": 0,
  "lowQCoverImage": coverImage,
  "language": "en",
  "origin": AppConstants.youtubeDirName,
});
5

Save Metadata

Store audiobook and file metadata locally:
Metadata Storage (youtube_webview.dart:216-230)
Future<void> _saveYouTubeAudiobookMetadata(
    Audiobook audiobook, List<AudiobookFile> files) async {
  final appDir = await getExternalStorageDirectory();
  if (appDir == null) {
    throw Exception('Could not access storage directory');
  }
  
  final audiobookDir = Directory(
    p.join(appDir.path, AppConstants.youtubeDirName, audiobook.id)
  );
  await audiobookDir.create(recursive: true);

  final metadataFile = File(
    p.join(audiobookDir.path, 'audiobook.txt')
  );
  await metadataFile.writeAsString(
    jsonEncode(audiobook.toMap())
  );

  final filesFile = File(
    p.join(audiobookDir.path, 'files.txt')
  );
  await filesFile.writeAsString(
    jsonEncode(files.map((f) => f.toJson()).toList())
  );
}

Duplicate Detection

The app prevents re-importing already imported content:
void _checkIfCurrentContentIsImported() {
  if (_currentUrl == null) {
    _isCurrentContentImported = false;
    return;
  }

  try {
    String? entityId;

    if (_currentUrl!.contains('playlist?list=')) {
      final uri = Uri.parse(_currentUrl!);
      entityId = uri.queryParameters['list'];
    } else if (_currentUrl!.contains('youtube.com/watch')) {
      final uri = Uri.parse(_currentUrl!);
      entityId = uri.queryParameters['v'];
    }

    if (entityId != null) {
      final wasImported = _isCurrentContentImported;
      _isCurrentContentImported =
          _audioBookNotifier.isAudiobookAlreadyImported(entityId);

      if (wasImported != _isCurrentContentImported) {
        setState(() {});
      }
    } else {
      _isCurrentContentImported = false;
    }
  } catch (e) {
    _isCurrentContentImported = false;
  }
}

Import Button

The floating action button adapts based on import status:
floatingActionButton: _currentUrl != null &&
        (_currentUrl!.contains('youtube.com/watch') ||
            _currentUrl!.contains('youtube.com/playlist'))
    ? FloatingActionButton(
        heroTag: 'import',
        backgroundColor: AppColors.primaryColor,
        onPressed: _isImporting
            ? null
            : _isCurrentContentImported
                ? () => context.go('/home')
                : _importFromYouTube,
        child: _isImporting
            ? const CircularProgressIndicator(
                strokeWidth: 2,
                color: Colors.white,
              )
            : _isCurrentContentImported
                ? const Icon(Icons.check_circle, color: Colors.white)
                : const Icon(Icons.add_to_queue, color: Colors.white),
      )
    : null,

Not Imported

Shows add icon when content hasn’t been imported

Importing

Shows loading spinner during import

Imported

Shows checkmark if already imported

WebView Keep-Alive

The WebView state persists across navigation:
InAppWebView(
  keepAlive: Provider.of<WebViewKeepAliveProvider>(context, listen: false)
      .keepAlive,
  // ...
)
The WebView maintains your browsing session even when navigating away from the import screen.

Error Handling

if (!_currentUrl!.contains('youtube.com/watch') &&
    !_currentUrl!.contains('youtube.com/playlist')) {
  setState(() => _errorMessageYT =
      'Please navigate to a YouTube video or playlist page');
  return;
}
if (videos.isEmpty) {
  throw Exception("Playlist contains no videos.");
}
catch (e) {
  if (mounted) {
    setState(() => _errorMessageYT = 
        'Error importing from YouTube: $e');
  }
} finally {
  if (mounted) setState(() => _isImporting = false);
}

Playback

Imported YouTube audiobooks are streamed on-demand:
No Pre-Download Required: YouTube imports stream audio directly without downloading. Use the download feature if you need offline access.
YouTube imports support all standard player features including speed control, sleep timer, and Chromecast!

Next Steps

Learn how to import audiobooks from local storage

Build docs developers (and LLMs) love