diff --git a/lib/main.dart b/lib/main.dart index 33aa5c349..04ff52c00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:finamp/services/offline_listen_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; @@ -18,37 +19,37 @@ import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import 'generate_material_color.dart'; +import 'models/finamp_models.dart'; +import 'models/jellyfin_models.dart'; import 'models/locale_adapter.dart'; import 'models/theme_mode_adapter.dart'; -import 'screens/language_selection_screen.dart'; -import 'services/locale_helper.dart'; -import 'services/theme_mode_helper.dart'; -import 'setup_logging.dart'; -import 'screens/user_selector.dart'; -import 'screens/music_screen.dart'; -import 'screens/view_selector.dart'; +import 'screens/add_download_location_screen.dart'; +import 'screens/add_to_playlist_screen.dart'; import 'screens/album_screen.dart'; -import 'screens/player_screen.dart'; -import 'screens/splash_screen.dart'; +import 'screens/artist_screen.dart'; +import 'screens/audio_service_settings_screen.dart'; import 'screens/downloads_error_screen.dart'; import 'screens/downloads_screen.dart'; -import 'screens/artist_screen.dart'; +import 'screens/downloads_settings_screen.dart'; +import 'screens/language_selection_screen.dart'; +import 'screens/layout_settings_screen.dart'; import 'screens/logs_screen.dart'; +import 'screens/music_screen.dart'; +import 'screens/player_screen.dart'; import 'screens/settings_screen.dart'; -import 'screens/transcoding_settings_screen.dart'; -import 'screens/downloads_settings_screen.dart'; -import 'screens/add_download_location_screen.dart'; -import 'screens/audio_service_settings_screen.dart'; +import 'screens/splash_screen.dart'; import 'screens/tabs_settings_screen.dart'; -import 'screens/add_to_playlist_screen.dart'; -import 'screens/layout_settings_screen.dart'; +import 'screens/transcoding_settings_screen.dart'; +import 'screens/user_selector.dart'; +import 'screens/view_selector.dart'; import 'services/audio_service_helper.dart'; -import 'services/jellyfin_api_helper.dart'; -import 'services/downloads_helper.dart'; import 'services/download_update_stream.dart'; +import 'services/downloads_helper.dart'; +import 'services/jellyfin_api_helper.dart'; +import 'services/locale_helper.dart'; import 'services/music_player_background_task.dart'; -import 'models/jellyfin_models.dart'; -import 'models/finamp_models.dart'; +import 'services/theme_mode_helper.dart'; +import 'setup_logging.dart'; void main() async { // If the app has failed, this is set to true. If true, we don't attempt to run the main app since the error app has started. @@ -60,6 +61,7 @@ void main() async { _migrateSortOptions(); _setupFinampUserHelper(); _setupJellyfinApiData(); + _setupOfflineListenLogHelper(); await _setupDownloader(); await _setupDownloadsHelper(); await _setupAudioServiceHelper(); @@ -91,6 +93,10 @@ void _setupJellyfinApiData() { GetIt.instance.registerSingleton(JellyfinApiHelper()); } +void _setupOfflineListenLogHelper() { + GetIt.instance.registerSingleton(OfflineListenLogHelper()); +} + Future _setupDownloadsHelper() async { GetIt.instance.registerSingleton(DownloadsHelper()); final downloadsHelper = GetIt.instance(); @@ -169,6 +175,7 @@ Future setupHive() async { Hive.registerAdapter(DownloadedImageAdapter()); Hive.registerAdapter(ThemeModeAdapter()); Hive.registerAdapter(LocaleAdapter()); + Hive.registerAdapter(OfflineListenAdapter()); await Future.wait([ Hive.openBox("DownloadedParents"), Hive.openBox("DownloadedItems"), @@ -180,6 +187,7 @@ Future setupHive() async { Hive.openBox("DownloadedImageIds"), Hive.openBox("ThemeMode"), Hive.openBox(LocaleHelper.boxName), + Hive.openBox("OfflineListens") ]); // If the settings box is empty, we add an initial settings value here. @@ -485,4 +493,4 @@ class _DummyCallback { IsolateNameServer.lookupPortByName('downloader_send_port'); send!.send([id, status, progress]); } -} +} \ No newline at end of file diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index e4d037a28..618947427 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -5,12 +5,12 @@ import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:uuid/uuid.dart'; import 'package:path/path.dart' as path_helper; +import 'package:uuid/uuid.dart'; import '../services/finamp_settings_helper.dart'; -import 'jellyfin_models.dart'; import '../services/get_internal_song_dir.dart'; +import 'jellyfin_models.dart'; part 'finamp_models.g.dart'; @@ -571,3 +571,39 @@ class DownloadedImage { downloadLocationId: downloadLocationId, ); } + +@HiveType(typeId: 43) +class OfflineListen { + OfflineListen({ + required this.timestamp, + required this.userId, + required this.itemId, + required this.name, + this.artist, + this.album, + this.trackMbid, + }); + + /// The stop timestamp of the listen, measured in seconds since the epoch. + @HiveField(0) + int timestamp; + + @HiveField(1) + String userId; + + @HiveField(2) + String itemId; + + @HiveField(3) + String name; + + @HiveField(4) + String? artist; + + @HiveField(5) + String? album; + + // The MusicBrainz ID of the track, if available. + @HiveField(6) + String? trackMbid; +} \ No newline at end of file diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index e8e2fe943..36d1d1b57 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -372,6 +372,58 @@ class DownloadedImageAdapter extends TypeAdapter { typeId == other.typeId; } +class OfflineListenAdapter extends TypeAdapter { + @override + final int typeId = 43; + + @override + OfflineListen read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return OfflineListen( + timestamp: fields[0] as int, + userId: fields[1] as String, + itemId: fields[2] as String, + name: fields[3] as String, + artist: fields[4] as String?, + album: fields[5] as String?, + trackMbid: fields[6] as String?, + ); + } + + @override + void write(BinaryWriter writer, OfflineListen obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.timestamp) + ..writeByte(1) + ..write(obj.userId) + ..writeByte(2) + ..write(obj.itemId) + ..writeByte(3) + ..write(obj.name) + ..writeByte(4) + ..write(obj.artist) + ..writeByte(5) + ..write(obj.album) + ..writeByte(6) + ..write(obj.trackMbid); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OfflineListenAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + class TabContentTypeAdapter extends TypeAdapter { @override final int typeId = 36; diff --git a/lib/services/finamp_user_helper.dart b/lib/services/finamp_user_helper.dart index 03124be25..f8662919f 100644 --- a/lib/services/finamp_user_helper.dart +++ b/lib/services/finamp_user_helper.dart @@ -13,6 +13,9 @@ class FinampUserHelper { /// Checks if there are any saved users. bool get isUsersEmpty => _finampUserBox.isEmpty; + /// Loads the id from CurrentUserId. Returns null if no id is stored. + String? get currentUserId => _currentUserIdBox.get("CurrentUserId"); + /// Loads the FinampUser with the id from CurrentUserId. Returns null if no /// user exists. FinampUser? get currentUser => @@ -61,4 +64,4 @@ class FinampUserHelper { _finampUserBox.delete(id); } -} +} \ No newline at end of file diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 1867fba2d..9292148ca 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:android_id/android_id.dart'; import 'package:audio_service/audio_service.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:finamp/services/offline_listen_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; import 'package:just_audio/just_audio.dart'; @@ -97,6 +98,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { ); final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask"); final _jellyfinApiHelper = GetIt.instance(); + final _offlineListenLogHelper = GetIt.instance(); final _finampUserHelper = GetIt.instance(); /// Set when shuffle mode is changed. If true, [onUpdateQueue] will create a @@ -203,6 +205,12 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { if (playbackInfo != null) { await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); } + } else { + final currentIndex = _player.currentIndex; + if (_queueAudioSource.length != 0 && currentIndex != null) { + final item = _getQueueItem(currentIndex); + _offlineListenLogHelper.logOfflineListen(item); + } } // Stop playing audio. @@ -446,34 +454,40 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { MediaItem? previousItem, PlaybackState? previousState, ) async { - if (FinampSettingsHelper.finampSettings.isOffline) { - return; - } - - final jellyfinApiHelper = GetIt.instance(); + final isOffline = FinampSettingsHelper.finampSettings.isOffline; if (previousItem != null && previousState != null && // don't submit stop events for idle tracks (at position 0 and not playing) (previousState.playing || previousState.updatePosition != Duration.zero)) { - final playbackData = generatePlaybackProgressInfoFromState( - previousItem, - previousState, - ); + if (!isOffline) { + final playbackData = generatePlaybackProgressInfoFromState( + previousItem, + previousState, + ); - if (playbackData != null) { - await jellyfinApiHelper.stopPlaybackProgress(playbackData); + if (playbackData != null) { + try { + await _jellyfinApiHelper.stopPlaybackProgress(playbackData); + } catch (e) { + _offlineListenLogHelper.logOfflineListen(previousItem); + } + } + } else { + _offlineListenLogHelper.logOfflineListen(previousItem); } } - final playbackData = generatePlaybackProgressInfoFromState( - currentItem, - currentState, - ); + if (!isOffline) { + final playbackData = generatePlaybackProgressInfoFromState( + currentItem, + currentState, + ); - if (playbackData != null) { - await jellyfinApiHelper.reportPlaybackStart(playbackData); + if (playbackData != null) { + await _jellyfinApiHelper.reportPlaybackStart(playbackData); + } } } diff --git a/lib/services/offline_listen_helper.dart b/lib/services/offline_listen_helper.dart new file mode 100644 index 000000000..0affc86d5 --- /dev/null +++ b/lib/services/offline_listen_helper.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Logs offline listens or failed submissions to a file. +class OfflineListenLogHelper { + final _logger = Logger("OfflineListenLogHelper"); + final _finampUserHelper = GetIt.instance(); + + Future get _logDirectory async { + if (!Platform.isAndroid) { + return await getApplicationDocumentsDirectory(); + } + + final List? dirs = + await getExternalStorageDirectories(type: StorageDirectory.documents); + return dirs?.first ?? await getApplicationDocumentsDirectory(); + } + + Future get _logFile async { + final Directory directory = await _logDirectory; + return File("${directory.path}/listens.json"); + } + + /// Logs a listen to a file. + /// + /// This is used when the user is offline or submitting live playback events fails. + Future logOfflineListen(MediaItem item) async { + final itemJson = item.extras!["itemJson"]; + + final offlineListen = OfflineListen( + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + userId: _finampUserHelper.currentUserId!, + itemId: itemJson["Id"], + name: itemJson["Name"], + artist: itemJson["AlbumArtist"], + album: itemJson["Album"], + trackMbid: itemJson["ProviderIds"]?["MusicBrainzTrack"], + ); + + await _logOfflineListen(offlineListen); + } + + /// Logs a listen to a file. + /// + /// This is used when the user is offline or submitting live playback events fails. + /// The [timestamp] provided to this function should be in seconds + /// and marks the time the track was stopped. + Future _logOfflineListen(OfflineListen listen) async { + Hive.box("OfflineListens").add(listen); + + _exportOfflineListenToFile(listen); + } + + Future _exportOfflineListenToFile(OfflineListen listen) async { + final data = { + 'timestamp': listen.timestamp, + 'item_id': listen.itemId, + 'title': listen.name, + 'artist': listen.artist, + 'album': listen.album, + 'track_mbid': listen.trackMbid, + 'user_id': listen.userId, + }; + final content = json.encode(data) + Platform.lineTerminator; + + final file = await _logFile; + try { + file.writeAsString(content, mode: FileMode.append, flush: true); + } catch (e) { + _logger.warning("Failed to write listen to file: $content"); + } + } +} \ No newline at end of file