From cfd014d38cba03ba05f571597b361ab253bff578 Mon Sep 17 00:00:00 2001 From: Scott Jackson Date: Sat, 25 Apr 2015 17:03:02 -0700 Subject: Update to gradle --- .../dsub/activity/DownloadActivity.java | 62 + .../dsub/activity/EditPlayActionActivity.java | 246 ++ .../dsub/activity/QueryReceiverActivity.java | 85 + .../dsub/activity/SettingsActivity.java | 91 + .../dsub/activity/SubsonicActivity.java | 860 +++++++ .../dsub/activity/SubsonicFragmentActivity.java | 686 ++++++ .../dsub/activity/VoiceQueryReceiverActivity.java | 61 + .../daneren2005/dsub/adapter/AlbumGridAdapter.java | 73 + .../daneren2005/dsub/adapter/AlbumListAdapter.java | 154 ++ .../daneren2005/dsub/adapter/ArtistAdapter.java | 97 + .../daneren2005/dsub/adapter/BookmarkAdapter.java | 64 + .../daneren2005/dsub/adapter/ChatAdapter.java | 109 + .../dsub/adapter/DownloadFileAdapter.java | 49 + .../daneren2005/dsub/adapter/DrawerAdapter.java | 126 + .../daneren2005/dsub/adapter/EntryAdapter.java | 82 + .../daneren2005/dsub/adapter/GenreAdapter.java | 60 + .../daneren2005/dsub/adapter/MergeAdapter.java | 290 +++ .../daneren2005/dsub/adapter/PlaylistAdapter.java | 70 + .../dsub/adapter/PodcastChannelAdapter.java | 60 + .../dsub/adapter/SackOfViewsAdapter.java | 181 ++ .../daneren2005/dsub/adapter/SettingsAdapter.java | 59 + .../daneren2005/dsub/adapter/ShareAdapter.java | 56 + .../daneren2005/dsub/adapter/UserAdapter.java | 52 + .../dsub/audiofx/AudioEffectsController.java | 69 + .../dsub/audiofx/EqualizerController.java | 198 ++ .../dsub/audiofx/LoudnessEnhancerController.java | 77 + .../github/daneren2005/dsub/domain/Artist.java | 145 ++ .../github/daneren2005/dsub/domain/ArtistInfo.java | 76 + .../github/daneren2005/dsub/domain/Bookmark.java | 105 + .../daneren2005/dsub/domain/ChatMessage.java | 51 + .../github/daneren2005/dsub/domain/DLNADevice.java | 78 + .../java/github/daneren2005/dsub/domain/Genre.java | 69 + .../github/daneren2005/dsub/domain/Indexes.java | 94 + .../github/daneren2005/dsub/domain/Lyrics.java | 57 + .../daneren2005/dsub/domain/MusicDirectory.java | 559 +++++ .../daneren2005/dsub/domain/MusicFolder.java | 49 + .../daneren2005/dsub/domain/PlayerQueue.java | 30 + .../daneren2005/dsub/domain/PlayerState.java | 47 + .../github/daneren2005/dsub/domain/Playlist.java | 128 ++ .../daneren2005/dsub/domain/PodcastChannel.java | 145 ++ .../daneren2005/dsub/domain/PodcastEpisode.java | 54 + .../dsub/domain/RemoteControlState.java | 38 + .../daneren2005/dsub/domain/RemoteStatus.java | 63 + .../github/daneren2005/dsub/domain/RepeatMode.java | 28 + .../daneren2005/dsub/domain/SearchCritera.java | 55 + .../daneren2005/dsub/domain/SearchResult.java | 52 + .../github/daneren2005/dsub/domain/ServerInfo.java | 213 ++ .../java/github/daneren2005/dsub/domain/Share.java | 165 ++ .../java/github/daneren2005/dsub/domain/User.java | 117 + .../github/daneren2005/dsub/domain/Version.java | 181 ++ .../daneren2005/dsub/fragments/AdminFragment.java | 147 ++ .../daneren2005/dsub/fragments/ChatFragment.java | 250 ++ .../dsub/fragments/DownloadFragment.java | 189 ++ .../dsub/fragments/EqualizerFragment.java | 441 ++++ .../daneren2005/dsub/fragments/LyricsFragment.java | 107 + .../daneren2005/dsub/fragments/MainFragment.java | 586 +++++ .../dsub/fragments/NowPlayingFragment.java | 1568 +++++++++++++ .../dsub/fragments/PreferenceCompatFragment.java | 313 +++ .../daneren2005/dsub/fragments/SearchFragment.java | 368 +++ .../dsub/fragments/SelectArtistFragment.java | 333 +++ .../dsub/fragments/SelectBookmarkFragment.java | 131 ++ .../dsub/fragments/SelectDirectoryFragment.java | 1597 +++++++++++++ .../dsub/fragments/SelectGenreFragment.java | 71 + .../dsub/fragments/SelectListFragment.java | 163 ++ .../dsub/fragments/SelectPlaylistFragment.java | 303 +++ .../dsub/fragments/SelectPodcastsFragment.java | 308 +++ .../dsub/fragments/SelectShareFragment.java | 216 ++ .../dsub/fragments/SelectVideoFragment.java | 82 + .../dsub/fragments/SelectYearFragment.java | 78 + .../dsub/fragments/SettingsFragment.java | 724 ++++++ .../dsub/fragments/SimilarArtistFragment.java | 169 ++ .../dsub/fragments/SubsonicFragment.java | 1817 +++++++++++++++ .../daneren2005/dsub/fragments/UserFragment.java | 125 + .../dsub/provider/DLNARouteProvider.java | 425 ++++ .../dsub/provider/DSubSearchProvider.java | 191 ++ .../daneren2005/dsub/provider/DSubWidget4x1.java | 28 + .../daneren2005/dsub/provider/DSubWidget4x2.java | 28 + .../daneren2005/dsub/provider/DSubWidget4x3.java | 28 + .../daneren2005/dsub/provider/DSubWidget4x4.java | 28 + .../dsub/provider/DSubWidgetProvider.java | 304 +++ .../dsub/provider/JukeboxRouteProvider.java | 131 ++ .../dsub/provider/MostRecentStubProvider.java | 61 + .../dsub/provider/PlaylistStubProvider.java | 61 + .../dsub/provider/PodcastStubProvider.java | 61 + .../dsub/provider/StarredStubProvider.java | 61 + .../dsub/receiver/A2dpIntentReceiver.java | 47 + .../dsub/receiver/AudioNoisyReceiver.java | 51 + .../daneren2005/dsub/receiver/BootReceiver.java | 34 + .../dsub/receiver/HeadphonePlugReceiver.java | 42 + .../dsub/receiver/MediaButtonIntentReceiver.java | 57 + .../dsub/receiver/PlayActionReceiver.java | 46 + .../dsub/service/CachedMusicService.java | 1424 ++++++++++++ .../dsub/service/ChromeCastController.java | 522 +++++ .../daneren2005/dsub/service/DLNAController.java | 687 ++++++ .../daneren2005/dsub/service/DownloadFile.java | 607 +++++ .../daneren2005/dsub/service/DownloadService.java | 2410 ++++++++++++++++++++ .../service/DownloadServiceLifecycleSupport.java | 445 ++++ .../dsub/service/HeadphoneListenerService.java | 66 + .../dsub/service/JukeboxController.java | 307 +++ .../dsub/service/MediaStoreService.java | 187 ++ .../daneren2005/dsub/service/MusicService.java | 197 ++ .../dsub/service/MusicServiceFactory.java | 36 + .../daneren2005/dsub/service/OfflineException.java | 32 + .../dsub/service/OfflineMusicService.java | 836 +++++++ .../daneren2005/dsub/service/RESTMusicService.java | 1991 ++++++++++++++++ .../daneren2005/dsub/service/RemoteController.java | 116 + .../github/daneren2005/dsub/service/Scrobbler.java | 85 + .../dsub/service/ServerTooOldException.java | 60 + .../dsub/service/parser/AbstractParser.java | 150 ++ .../dsub/service/parser/AlbumListParser.java | 61 + .../dsub/service/parser/ArtistInfoParser.java | 82 + .../dsub/service/parser/BookmarkParser.java | 100 + .../dsub/service/parser/ChatMessageParser.java | 65 + .../dsub/service/parser/ErrorParser.java | 49 + .../dsub/service/parser/GenreParser.java | 122 + .../dsub/service/parser/IndexesParser.java | 134 ++ .../dsub/service/parser/JukeboxStatusParser.java | 62 + .../dsub/service/parser/LicenseParser.java | 62 + .../dsub/service/parser/LyricsParser.java | 64 + .../service/parser/MusicDirectoryEntryParser.java | 94 + .../dsub/service/parser/MusicDirectoryParser.java | 108 + .../dsub/service/parser/MusicFoldersParser.java | 65 + .../dsub/service/parser/PlayQueueParser.java | 85 + .../dsub/service/parser/PlaylistParser.java | 63 + .../dsub/service/parser/PlaylistsParser.java | 70 + .../dsub/service/parser/PodcastChannelParser.java | 66 + .../dsub/service/parser/PodcastEntryParser.java | 112 + .../dsub/service/parser/RandomSongsParser.java | 60 + .../dsub/service/parser/ScanStatusParser.java | 56 + .../dsub/service/parser/SearchResult2Parser.java | 75 + .../dsub/service/parser/SearchResultParser.java | 65 + .../dsub/service/parser/ShareParser.java | 126 + .../dsub/service/parser/StarredListParser.java | 69 + .../dsub/service/parser/SubsonicRESTException.java | 19 + .../dsub/service/parser/UserParser.java | 73 + .../dsub/service/parser/VideosParser.java | 53 + .../dsub/service/ssl/SSLSocketFactory.java | 549 +++++ .../dsub/service/ssl/TrustManagerDecorator.java | 65 + .../dsub/service/ssl/TrustSelfSignedStrategy.java | 44 + .../dsub/service/ssl/TrustStrategy.java | 57 + .../dsub/service/sync/AuthenticatorService.java | 90 + .../dsub/service/sync/MostRecentSyncAdapter.java | 105 + .../dsub/service/sync/MostRecentSyncService.java | 48 + .../dsub/service/sync/PlaylistSyncAdapter.java | 153 ++ .../dsub/service/sync/PlaylistSyncService.java | 48 + .../dsub/service/sync/PodcastSyncAdapter.java | 113 + .../dsub/service/sync/PodcastSyncService.java | 48 + .../dsub/service/sync/StarredSyncAdapter.java | 80 + .../dsub/service/sync/StarredSyncService.java | 48 + .../dsub/service/sync/SubsonicSyncAdapter.java | 174 ++ .../github/daneren2005/dsub/updates/Updater.java | 98 + .../daneren2005/dsub/updates/Updater403.java | 58 + .../daneren2005/dsub/util/ArtistRadioBuffer.java | 148 ++ .../daneren2005/dsub/util/BackgroundTask.java | 307 +++ .../github/daneren2005/dsub/util/CacheCleaner.java | 292 +++ .../github/daneren2005/dsub/util/Constants.java | 206 ++ .../github/daneren2005/dsub/util/FileUtil.java | 860 +++++++ .../github/daneren2005/dsub/util/ImageLoader.java | 600 +++++ .../github/daneren2005/dsub/util/LoadingTask.java | 73 + .../daneren2005/dsub/util/MediaRouteManager.java | 181 ++ .../daneren2005/dsub/util/Notifications.java | 348 +++ .../java/github/daneren2005/dsub/util/Pair.java | 54 + .../daneren2005/dsub/util/ProgressListener.java | 27 + .../daneren2005/dsub/util/SettingsBackupAgent.java | 31 + .../daneren2005/dsub/util/ShufflePlayBuffer.java | 212 ++ .../dsub/util/SilentBackgroundTask.java | 48 + .../daneren2005/dsub/util/SimpleServiceBinder.java | 37 + .../github/daneren2005/dsub/util/SyncUtil.java | 222 ++ .../daneren2005/dsub/util/TabBackgroundTask.java | 51 + .../daneren2005/dsub/util/TimeLimitedCache.java | 55 + .../github/daneren2005/dsub/util/UserUtil.java | 452 ++++ .../java/github/daneren2005/dsub/util/Util.java | 1339 +++++++++++ .../daneren2005/dsub/util/compat/CastCompat.java | 57 + .../dsub/util/compat/RemoteControlClientBase.java | 43 + .../util/compat/RemoteControlClientHelper.java | 32 + .../dsub/util/compat/RemoteControlClientICS.java | 104 + .../dsub/util/compat/RemoteControlClientJB.java | 58 + .../github/daneren2005/dsub/util/tags/Bastp.java | 85 + .../daneren2005/dsub/util/tags/BastpUtil.java | 73 + .../github/daneren2005/dsub/util/tags/Common.java | 111 + .../daneren2005/dsub/util/tags/FlacFile.java | 85 + .../daneren2005/dsub/util/tags/ID3v2File.java | 176 ++ .../daneren2005/dsub/util/tags/LameHeader.java | 70 + .../github/daneren2005/dsub/util/tags/OggFile.java | 114 + .../github/daneren2005/dsub/view/AlbumCell.java | 108 + .../github/daneren2005/dsub/view/AlbumView.java | 107 + .../daneren2005/dsub/view/ArtistEntryView.java | 79 + .../github/daneren2005/dsub/view/ArtistView.java | 78 + .../daneren2005/dsub/view/AutoRepeatButton.java | 86 + .../github/daneren2005/dsub/view/ChangeLog.java | 546 +++++ .../github/daneren2005/dsub/view/ErrorDialog.java | 75 + .../daneren2005/dsub/view/FadeOutAnimation.java | 77 + .../github/daneren2005/dsub/view/GenreView.java | 58 + .../daneren2005/dsub/view/HeaderGridView.java | 836 +++++++ .../dsub/view/MyLeadingMarginSpan2.java | 34 + .../daneren2005/dsub/view/MyViewFlipper.java | 53 + .../daneren2005/dsub/view/PlaylistSongView.java | 102 + .../github/daneren2005/dsub/view/PlaylistView.java | 69 + .../daneren2005/dsub/view/PodcastChannelView.java | 87 + .../daneren2005/dsub/view/RecyclingImageView.java | 91 + .../daneren2005/dsub/view/SeekBarPreference.java | 156 ++ .../github/daneren2005/dsub/view/SettingView.java | 102 + .../github/daneren2005/dsub/view/ShareView.java | 65 + .../github/daneren2005/dsub/view/SongView.java | 318 +++ .../daneren2005/dsub/view/SquareImageView.java | 32 + .../dsub/view/UnscrollableGridView.java | 128 ++ .../github/daneren2005/dsub/view/UpdateView.java | 286 +++ .../github/daneren2005/dsub/view/UserView.java | 54 + 208 files changed, 42560 insertions(+) create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/DownloadActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/EditPlayActionActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/QueryReceiverActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/SettingsActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/SubsonicActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/SubsonicFragmentActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/activity/VoiceQueryReceiverActivity.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/AlbumGridAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/AlbumListAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/ArtistAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/BookmarkAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/ChatAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/DownloadFileAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/DrawerAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/EntryAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/GenreAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/MergeAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/PlaylistAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/PodcastChannelAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/SackOfViewsAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/SettingsAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/ShareAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/adapter/UserAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/audiofx/AudioEffectsController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/audiofx/EqualizerController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/audiofx/LoudnessEnhancerController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Artist.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/ArtistInfo.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Bookmark.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/ChatMessage.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/DLNADevice.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Genre.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Indexes.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Lyrics.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/MusicDirectory.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/MusicFolder.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/PlayerQueue.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/PlayerState.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Playlist.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/PodcastChannel.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/PodcastEpisode.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/RemoteControlState.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/RemoteStatus.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/RepeatMode.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/SearchCritera.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/SearchResult.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/ServerInfo.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Share.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/User.java create mode 100644 app/src/main/java/github/daneren2005/dsub/domain/Version.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/AdminFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/ChatFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/DownloadFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/EqualizerFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/LyricsFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/MainFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/NowPlayingFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/PreferenceCompatFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SearchFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectArtistFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectBookmarkFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectDirectoryFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectGenreFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectListFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectPlaylistFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectPodcastsFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectShareFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectVideoFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SelectYearFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SettingsFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SimilarArtistFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/SubsonicFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/fragments/UserFragment.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DLNARouteProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DSubSearchProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x1.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x2.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x3.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x4.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/DSubWidgetProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/JukeboxRouteProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/MostRecentStubProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/PlaylistStubProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/PodcastStubProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/provider/StarredStubProvider.java create mode 100644 app/src/main/java/github/daneren2005/dsub/receiver/A2dpIntentReceiver.java create mode 100644 app/src/main/java/github/daneren2005/dsub/receiver/AudioNoisyReceiver.java create mode 100644 app/src/main/java/github/daneren2005/dsub/receiver/BootReceiver.java create mode 100644 app/src/main/java/github/daneren2005/dsub/receiver/HeadphonePlugReceiver.java create mode 100644 app/src/main/java/github/daneren2005/dsub/receiver/MediaButtonIntentReceiver.java create mode 100644 app/src/main/java/github/daneren2005/dsub/receiver/PlayActionReceiver.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/CachedMusicService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/ChromeCastController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/DLNAController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/DownloadFile.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/DownloadService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/DownloadServiceLifecycleSupport.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/HeadphoneListenerService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/JukeboxController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/MediaStoreService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/MusicService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/MusicServiceFactory.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/OfflineException.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/OfflineMusicService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/RESTMusicService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/RemoteController.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/Scrobbler.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/ServerTooOldException.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/AbstractParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/AlbumListParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/ArtistInfoParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/BookmarkParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/ChatMessageParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/ErrorParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/GenreParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/IndexesParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/JukeboxStatusParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/LicenseParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/LyricsParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryEntryParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/MusicFoldersParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/PlayQueueParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistsParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/PodcastChannelParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/PodcastEntryParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/RandomSongsParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/ScanStatusParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/SearchResult2Parser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/SearchResultParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/ShareParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/StarredListParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/SubsonicRESTException.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/UserParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/parser/VideosParser.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/ssl/SSLSocketFactory.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/ssl/TrustManagerDecorator.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/ssl/TrustSelfSignedStrategy.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/ssl/TrustStrategy.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/AuthenticatorService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncService.java create mode 100644 app/src/main/java/github/daneren2005/dsub/service/sync/SubsonicSyncAdapter.java create mode 100644 app/src/main/java/github/daneren2005/dsub/updates/Updater.java create mode 100644 app/src/main/java/github/daneren2005/dsub/updates/Updater403.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/Constants.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/FileUtil.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/Notifications.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/Pair.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/UserUtil.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/Util.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/Common.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java create mode 100644 app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/AlbumCell.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/AlbumView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/ArtistEntryView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/ArtistView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/AutoRepeatButton.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/ChangeLog.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/ErrorDialog.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/FadeOutAnimation.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/GenreView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/HeaderGridView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/MyLeadingMarginSpan2.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/MyViewFlipper.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/PlaylistSongView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/PlaylistView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/PodcastChannelView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/RecyclingImageView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/SeekBarPreference.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/SettingView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/ShareView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/SongView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/SquareImageView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/UnscrollableGridView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/UpdateView.java create mode 100644 app/src/main/java/github/daneren2005/dsub/view/UserView.java (limited to 'app/src/main/java/github/daneren2005') diff --git a/app/src/main/java/github/daneren2005/dsub/activity/DownloadActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/DownloadActivity.java new file mode 100644 index 00000000..e13a8b8c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/DownloadActivity.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.activity; + +import github.daneren2005.dsub.R; +import android.os.Bundle; +import android.view.MotionEvent; +import github.daneren2005.dsub.fragments.NowPlayingFragment; + +import android.widget.EditText; + +import github.daneren2005.dsub.util.Constants; + +public class DownloadActivity extends SubsonicActivity { + private static final String TAG = DownloadActivity.class.getSimpleName(); + private EditText playlistNameView; + + /** + * Called when the activity is first created. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.download_activity); + + if (findViewById(R.id.fragment_container) != null && savedInstanceState == null) { + currentFragment = new NowPlayingFragment(); + if(getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD_VIEW)) { + Bundle args = new Bundle(); + args.putBoolean(Constants.INTENT_EXTRA_NAME_DOWNLOAD_VIEW, true); + currentFragment.setArguments(args); + } + currentFragment.setPrimaryFragment(true); + getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, currentFragment, currentFragment.getSupportTag() + "").commit(); + } + } + + @Override + public boolean onTouchEvent(MotionEvent me) { + if(currentFragment != null && currentFragment.getGestureDetector() != null) { + return currentFragment.getGestureDetector().onTouchEvent(me); + } else { + return false; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/activity/EditPlayActionActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/EditPlayActionActivity.java new file mode 100644 index 00000000..e1f2cad3 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/EditPlayActionActivity.java @@ -0,0 +1,246 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.activity; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.widget.DrawerLayout; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.Spinner; + +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.Util; + +public class EditPlayActionActivity extends SubsonicActivity { + private CheckBox shuffleCheckbox; + private CheckBox startYearCheckbox; + private EditText startYearBox; + private CheckBox endYearCheckbox; + private EditText endYearBox; + private Button genreButton; + private Spinner offlineSpinner; + + private String doNothing; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.tasker_start_playing_title); + setContentView(R.layout.edit_play_action); + final Activity context = this; + doNothing = context.getResources().getString(R.string.tasker_edit_do_nothing); + + shuffleCheckbox = (CheckBox) findViewById(R.id.edit_shuffle_checkbox); + shuffleCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton view, boolean isChecked) { + startYearCheckbox.setEnabled(isChecked); + endYearCheckbox.setEnabled(isChecked); + genreButton.setEnabled(isChecked); + } + }); + + startYearCheckbox = (CheckBox) findViewById(R.id.edit_start_year_checkbox); + startYearBox = (EditText) findViewById(R.id.edit_start_year); + // Disable/enable number box if checked + startYearCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton view, boolean isChecked) { + startYearBox.setEnabled(isChecked); + } + }); + + endYearCheckbox = (CheckBox) findViewById(R.id.edit_end_year_checkbox); + endYearBox = (EditText) findViewById(R.id.edit_end_year); + endYearCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton view, boolean isChecked) { + endYearBox.setEnabled(isChecked); + } + }); + + genreButton = (Button) findViewById(R.id.edit_genre_spinner); + genreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + new LoadingTask>(context, true) { + @Override + protected List doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.getGenres(false, context, this); + } + + @Override + protected void done(final List genres) { + List names = new ArrayList(); + String blank = context.getResources().getString(R.string.select_genre_blank); + names.add(doNothing); + names.add(blank); + for(Genre genre: genres) { + names.add(genre.getName()); + } + final List finalNames = names; + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.shuffle_pick_genre) + .setItems(names.toArray(new CharSequence[names.size()]), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if(which == 1) { + genreButton.setText(""); + } else { + genreButton.setText(finalNames.get(which)); + } + } + }); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.playlist_error) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + }); + genreButton.setText(doNothing); + + offlineSpinner = (Spinner) findViewById(R.id.edit_offline_spinner); + ArrayAdapter offlineAdapter = ArrayAdapter.createFromResource(this, R.array.editServerOptions, android.R.layout.simple_spinner_item); + offlineAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + offlineSpinner.setAdapter(offlineAdapter); + + // Setup default for everything + Bundle extras = getIntent().getBundleExtra(Constants.TASKER_EXTRA_BUNDLE); + if(extras != null) { + if(extras.getBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE)) { + shuffleCheckbox.setChecked(true); + } + + String startYear = extras.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, null); + if(startYear != null) { + startYearCheckbox.setEnabled(true); + startYearBox.setText(startYear); + } + String endYear = extras.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, null); + if(endYear != null) { + endYearCheckbox.setEnabled(true); + endYearBox.setText(endYear); + } + + String genre = extras.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, doNothing); + if(genre != null) { + genreButton.setText(genre); + } + + int offline = extras.getInt(Constants.PREFERENCES_KEY_OFFLINE, 0); + if(offline != 0) { + offlineSpinner.setSelection(offline); + } + } + + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater menuInflater = getMenuInflater(); + menuInflater.inflate(R.menu.tasker_configuration, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(item.getItemId() == android.R.id.home) { + cancel(); + return true; + } else if(item.getItemId() == R.id.menu_accept) { + accept(); + return true; + } else if(item.getItemId() == R.id.menu_cancel) { + cancel(); + return true; + } + + return false; + } + + private void accept() { + Intent intent = new Intent(); + + String blurb = getResources().getString(shuffleCheckbox.isChecked() ? R.string.tasker_start_playing_shuffled : R.string.tasker_start_playing); + intent.putExtra("com.twofortyfouram.locale.intent.extra.BLURB", blurb); + + // Get settings user specified + Bundle data = new Bundle(); + boolean shuffle = shuffleCheckbox.isChecked(); + data.putBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE, shuffle); + if(shuffle) { + if(startYearCheckbox.isChecked()) { + data.putString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, startYearBox.getText().toString()); + } + if(endYearCheckbox.isChecked()) { + data.putString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, endYearBox.getText().toString()); + } + String genre = genreButton.getText().toString(); + if(!genre.equals(doNothing)) { + data.putString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, genre); + } + } + + int offline = offlineSpinner.getSelectedItemPosition(); + if(offline != 0) { + data.putInt(Constants.PREFERENCES_KEY_OFFLINE, offline); + } + + intent.putExtra(Constants.TASKER_EXTRA_BUNDLE, data); + + setResult(Activity.RESULT_OK, intent); + finish(); + } + private void cancel() { + setResult(Activity.RESULT_CANCELED); + finish(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/activity/QueryReceiverActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/QueryReceiverActivity.java new file mode 100644 index 00000000..eefb9c56 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/QueryReceiverActivity.java @@ -0,0 +1,85 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.activity; + +import android.app.Activity; +import android.app.SearchManager; +import android.content.Intent; +import android.os.Bundle; +import android.provider.SearchRecentSuggestions; +import android.util.Log; + +import github.daneren2005.dsub.fragments.SubsonicFragment; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.provider.DSubSearchProvider; + +/** + * Receives search queries and forwards to the SearchFragment. + * + * @author Sindre Mehus + */ +public class QueryReceiverActivity extends Activity { + + private static final String TAG = QueryReceiverActivity.class.getSimpleName(); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + if (Intent.ACTION_SEARCH.equals(intent.getAction())) { + doSearch(); + } else if(Intent.ACTION_VIEW.equals(intent.getAction())) { + showResult(intent.getDataString(), intent.getStringExtra(SearchManager.EXTRA_DATA_KEY)); + } + finish(); + Util.disablePendingTransition(this); + } + + private void doSearch() { + String query = getIntent().getStringExtra(SearchManager.QUERY); + if (query != null) { + Intent intent = new Intent(QueryReceiverActivity.this, SubsonicFragmentActivity.class); + intent.putExtra(Constants.INTENT_EXTRA_NAME_QUERY, query); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + Util.startActivityWithoutTransition(QueryReceiverActivity.this, intent); + } + } + private void showResult(String albumId, String name) { + if (albumId != null) { + Intent intent = new Intent(this, SubsonicFragmentActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(Constants.INTENT_EXTRA_VIEW_ALBUM, true); + if(albumId.indexOf("ar-") == 0) { + intent.putExtra(Constants.INTENT_EXTRA_NAME_ARTIST, true); + albumId = albumId.replace("ar-", ""); + } else if(albumId.indexOf("so-") == 0) { + intent.putExtra(Constants.INTENT_EXTRA_SEARCH_SONG, name); + albumId = albumId.replace("so-", ""); + } + intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, albumId); + if (name != null) { + intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, name); + } + Util.startActivityWithoutTransition(this, intent); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/activity/SettingsActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/SettingsActivity.java new file mode 100644 index 00000000..d5ac60d3 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/SettingsActivity.java @@ -0,0 +1,91 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.activity; + +import android.annotation.TargetApi; +import android.accounts.Account; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.preference.CheckBoxPreference; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceScreen; +import android.support.v7.app.ActionBarActivity; +import android.text.InputType; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.fragments.PreferenceCompatFragment; +import github.daneren2005.dsub.fragments.SettingsFragment; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.view.ErrorDialog; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URL; +import java.text.DecimalFormat; +import java.util.LinkedHashMap; +import java.util.Map; + +public class SettingsActivity extends SubsonicActivity { + private static final String TAG = SettingsActivity.class.getSimpleName(); + private PreferenceCompatFragment fragment; + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.download_activity); + + if (savedInstanceState == null) { + fragment = new SettingsFragment(); + Bundle args = new Bundle(); + args.putInt(Constants.INTENT_EXTRA_FRAGMENT_TYPE, R.xml.settings); + + fragment.setArguments(args); + fragment.setRetainInstance(true); + + currentFragment = fragment; + currentFragment.setPrimaryFragment(true); + getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, currentFragment, currentFragment.getSupportTag() + "").commit(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/activity/SubsonicActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/SubsonicActivity.java new file mode 100644 index 00000000..4651eb0b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/SubsonicActivity.java @@ -0,0 +1,860 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.activity; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.media.AudioManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.support.v7.app.ActionBarDrawerToggle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.widget.DrawerLayout; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.animation.AnimationUtils; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.Spinner; +import android.widget.TextView; + +import java.io.File; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.fragments.SubsonicFragment; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.HeadphoneListenerService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.DrawerAdapter; +import github.daneren2005.dsub.view.UpdateView; +import github.daneren2005.dsub.util.UserUtil; + +public class SubsonicActivity extends ActionBarActivity implements OnItemSelectedListener { + private static final String TAG = SubsonicActivity.class.getSimpleName(); + private static ImageLoader IMAGE_LOADER; + protected static String theme; + protected static boolean fullScreen; + private String[] drawerItemsDescriptions; + private String[] drawerItems; + private boolean drawerIdle = true; + private boolean[] enabledItems = {true, true, true, true, true}; + private boolean destroyed = false; + private boolean finished = false; + protected List backStack = new ArrayList(); + protected SubsonicFragment currentFragment; + protected View primaryContainer; + protected View secondaryContainer; + protected boolean tv = false; + protected boolean touchscreen = true; + Spinner actionBarSpinner; + ArrayAdapter spinnerAdapter; + ViewGroup rootView; + DrawerLayout drawer; + ActionBarDrawerToggle drawerToggle; + DrawerAdapter drawerAdapter; + ListView drawerList; + TextView lastSelectedView = null; + int lastSelectedPosition = 0; + boolean drawerOpen = false; + + @Override + protected void onCreate(Bundle bundle) { + UiModeManager uiModeManager = (UiModeManager) getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + // tv = true; + } + PackageManager pm = getPackageManager(); + if(!pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { + touchscreen = false; + } + + setUncaughtExceptionHandler(); + applyTheme(); + applyFullscreen(); + super.onCreate(bundle); + startService(new Intent(this, DownloadService.class)); + setVolumeControlStream(AudioManager.STREAM_MUSIC); + + View actionbar = getLayoutInflater().inflate(R.layout.actionbar_spinner, null); + actionBarSpinner = (Spinner)actionbar.findViewById(R.id.spinner); + spinnerAdapter = new ArrayAdapter(this, android.R.layout.simple_spinner_item); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + actionBarSpinner.setOnItemSelectedListener(this); + actionBarSpinner.setAdapter(spinnerAdapter); + + getSupportActionBar().setCustomView(actionbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setHomeButtonEnabled(true); + + if(getIntent().hasExtra(Constants.FRAGMENT_POSITION)) { + lastSelectedPosition = getIntent().getIntExtra(Constants.FRAGMENT_POSITION, 0); + } + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + // Sync the toggle state after onRestoreInstanceState has occurred. + if(drawerToggle != null) { + drawerToggle.syncState(); + } + + if(Util.shouldStartOnHeadphones(this)) { + Intent serviceIntent = new Intent(); + serviceIntent.setClassName(this.getPackageName(), HeadphoneListenerService.class.getName()); + this.startService(serviceIntent); + } + } + + @Override + protected void onResume() { + super.onResume(); + Util.registerMediaButtonEventReceiver(this); + + // Make sure to update theme + if (theme != null && !theme.equals(Util.getTheme(this)) || fullScreen != Util.getPreferences(this).getBoolean(Constants.PREFERENCES_KEY_FULL_SCREEN, false)) { + restart(); + overridePendingTransition(R.anim.fade_in, R.anim.fade_out); + } + + populateDrawer(); + UpdateView.addActiveActivity(); + } + + @Override + protected void onPause() { + super.onPause(); + + UpdateView.removeActiveActivity(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + destroyed = true; + } + + @Override + public void finish() { + super.finish(); + Util.disablePendingTransition(this); + } + + @Override + public void startActivity(Intent intent) { + if(intent.getComponent() != null) { + String name = intent.getComponent().getClassName(); + if(name != null && name.indexOf("DownloadActivity") != -1) { + intent.putExtra(Constants.FRAGMENT_POSITION, lastSelectedPosition); + } else if(name != null && name.indexOf("SettingsActivity") != -1) { + intent.putExtra(Constants.FRAGMENT_POSITION, drawerItems.length - 1); + } + } + super.startActivity(intent); + } + + @Override + public void setContentView(int viewId) { + if(isTv()) { + super.setContentView(R.layout.static_drawer_activity); + } else { + super.setContentView(R.layout.abstract_activity); + } + rootView = (ViewGroup) findViewById(R.id.content_frame); + + if(viewId != 0) { + LayoutInflater layoutInflater = getLayoutInflater(); + layoutInflater.inflate(viewId, rootView); + } + + drawerList = (ListView) findViewById(R.id.left_drawer); + drawerList.setOnItemClickListener(new ListView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, final View view, final int position, long id) { + final int actualPosition = drawerAdapter.getActualPosition(position); + if("Settings".equals(drawerItemsDescriptions[actualPosition])) { + startActivity(new Intent(SubsonicActivity.this, SettingsActivity.class)); + drawer.closeDrawers(); + } else if("Admin".equals(drawerItemsDescriptions[actualPosition]) && UserUtil.isCurrentAdmin()) { + UserUtil.confirmCredentials(SubsonicActivity.this, new Runnable() { + @Override + public void run() { + drawerItemSelected(actualPosition, view); + } + }); + } else { + drawerItemSelected(actualPosition, view); + } + } + }); + + + + if(!isTv()) { + drawer = (DrawerLayout) findViewById(R.id.drawer_layout); + + drawerToggle = new ActionBarDrawerToggle(this, drawer, R.string.common_appname, R.string.common_appname) { + @Override + public void onDrawerClosed(View view) { + setTitle(currentFragment.getTitle()); + + drawerIdle = true; + drawerOpen = false; + + supportInvalidateOptionsMenu(); + } + + @Override + public void onDrawerOpened(View view) { + DownloadService downloadService = getDownloadService(); + if (downloadService == null || downloadService.getBackgroundDownloads().isEmpty()) { + drawerAdapter.setDownloadVisible(false); + } else { + drawerAdapter.setDownloadVisible(true); + } + + if (lastSelectedView == null && drawerList.getCount() > lastSelectedPosition) { + lastSelectedView = (TextView) drawerList.getChildAt(lastSelectedPosition).findViewById(R.id.drawer_name); + if (lastSelectedView != null) { + lastSelectedView.setTextAppearance(SubsonicActivity.this, R.style.DSub_TextViewStyle_Bold); + } + } + + getSupportActionBar().setTitle(R.string.common_appname); + getSupportActionBar().setDisplayShowCustomEnabled(false); + + drawerIdle = true; + drawerOpen = true; + + supportInvalidateOptionsMenu(); + } + + @Override + public void onDrawerSlide(View drawerView, float slideOffset) { + super.onDrawerSlide(drawerView, slideOffset); + drawerIdle = false; + } + }; + drawer.setDrawerListener(drawerToggle); + drawerToggle.setDrawerIndicatorEnabled(false); + + drawer.setOnTouchListener(new View.OnTouchListener() { + public boolean onTouch(View v, MotionEvent event) { + if (drawerIdle && currentFragment != null && currentFragment.getGestureDetector() != null) { + return currentFragment.getGestureDetector().onTouchEvent(event); + } else { + return false; + } + } + }); + } + + // Check whether this is a tablet or not + secondaryContainer = findViewById(R.id.fragment_second_container); + if(secondaryContainer != null) { + primaryContainer = findViewById(R.id.fragment_container); + } + } + + @Override + public void onSaveInstanceState(Bundle savedInstanceState) { + super.onSaveInstanceState(savedInstanceState); + String[] ids = new String[backStack.size() + 1]; + ids[0] = currentFragment.getTag(); + int i = 1; + for(SubsonicFragment frag: backStack) { + ids[i] = frag.getTag(); + i++; + } + savedInstanceState.putStringArray(Constants.MAIN_BACK_STACK, ids); + savedInstanceState.putInt(Constants.MAIN_BACK_STACK_SIZE, backStack.size() + 1); + savedInstanceState.putInt(Constants.FRAGMENT_POSITION, lastSelectedPosition); + } + @Override + public void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + int size = savedInstanceState.getInt(Constants.MAIN_BACK_STACK_SIZE); + String[] ids = savedInstanceState.getStringArray(Constants.MAIN_BACK_STACK); + FragmentManager fm = getSupportFragmentManager(); + currentFragment = (SubsonicFragment)fm.findFragmentByTag(ids[0]); + currentFragment.setPrimaryFragment(true); + currentFragment.setSupportTag(ids[0]); + supportInvalidateOptionsMenu(); + FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); + for(int i = 1; i < size; i++) { + SubsonicFragment frag = (SubsonicFragment)fm.findFragmentByTag(ids[i]); + frag.setSupportTag(ids[i]); + if(secondaryContainer != null) { + frag.setPrimaryFragment(false, true); + } + trans.hide(frag); + backStack.add(frag); + } + trans.commit(); + + // Current fragment is hidden in secondaryContainer + if(secondaryContainer == null && !currentFragment.isVisible()) { + trans = getSupportFragmentManager().beginTransaction(); + trans.remove(currentFragment); + trans.commit(); + getSupportFragmentManager().executePendingTransactions(); + + trans = getSupportFragmentManager().beginTransaction(); + trans.add(R.id.fragment_container, currentFragment, ids[0]); + trans.commit(); + } + // Current fragment needs to be moved over to secondaryContainer + else if(secondaryContainer != null && secondaryContainer.findViewById(currentFragment.getRootId()) == null && backStack.size() > 0) { + trans = getSupportFragmentManager().beginTransaction(); + trans.remove(currentFragment); + trans.show(backStack.get(backStack.size() - 1)); + trans.commit(); + getSupportFragmentManager().executePendingTransactions(); + + trans = getSupportFragmentManager().beginTransaction(); + trans.add(R.id.fragment_second_container, currentFragment, ids[0]); + trans.commit(); + + secondaryContainer.setVisibility(View.VISIBLE); + } + + lastSelectedPosition = savedInstanceState.getInt(Constants.FRAGMENT_POSITION); + recreateSpinner(); + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater menuInflater = getMenuInflater(); + if(drawerOpen) { + menuInflater.inflate(R.menu.drawer_menu, menu); + } else if(currentFragment != null) { + try { + currentFragment.setContext(this); + currentFragment.onCreateOptionsMenu(menu, menuInflater); + + if(isTouchscreen()) { + menu.setGroupVisible(R.id.not_touchscreen, false); + } + } catch(Exception e) { + Log.w(TAG, "Error on creating options menu", e); + } + } + return true; + } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(drawerToggle != null && drawerToggle.onOptionsItemSelected(item)) { + return true; + } else if(item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + + return currentFragment.onOptionsItemSelected(item); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + boolean isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN; + boolean isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP; + boolean isVolumeAdjust = isVolumeDown || isVolumeUp; + boolean isJukebox = getDownloadService() != null && getDownloadService().isRemoteEnabled(); + + if (isVolumeAdjust && isJukebox) { + getDownloadService().updateRemoteVolume(isVolumeUp); + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public void setTitle(CharSequence title) { + if(title != null && !title.equals(getSupportActionBar().getTitle())) { + getSupportActionBar().setTitle(title); + recreateSpinner(); + } + } + public void setSubtitle(CharSequence title) { + getSupportActionBar().setSubtitle(title); + } + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + int top = spinnerAdapter.getCount() - 1; + if(position < top) { + for(int i = top; i > position; i--) { + removeCurrent(); + } + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + + private void populateDrawer() { + SharedPreferences prefs = Util.getPreferences(this); + boolean podcastsEnabled = prefs.getBoolean(Constants.PREFERENCES_KEY_PODCASTS_ENABLED, true); + boolean bookmarksEnabled = prefs.getBoolean(Constants.PREFERENCES_KEY_BOOKMARKS_ENABLED, true) && !Util.isOffline(this) && ServerInfo.canBookmark(this); + boolean sharedEnabled = prefs.getBoolean(Constants.PREFERENCES_KEY_SHARED_ENABLED, true) && !Util.isOffline(this); + boolean chatEnabled = prefs.getBoolean(Constants.PREFERENCES_KEY_CHAT_ENABLED, true) && !Util.isOffline(this); + boolean adminEnabled = prefs.getBoolean(Constants.PREFERENCES_KEY_ADMIN_ENABLED, true) && !Util.isOffline(this); + + if(drawerItems == null || !enabledItems[0] == podcastsEnabled || !enabledItems[1] == bookmarksEnabled || !enabledItems[2] == sharedEnabled || !enabledItems[3] == chatEnabled || !enabledItems[4] == adminEnabled) { + drawerItems = getResources().getStringArray(R.array.drawerItems); + drawerItemsDescriptions = getResources().getStringArray(R.array.drawerItemsDescriptions); + + List drawerItemsList = new ArrayList(Arrays.asList(drawerItems)); + List drawerItemsIconsList = new ArrayList(); + List drawerItemsVisibleList = new ArrayList(); + + int[] arrayAttr = {R.attr.drawerItemsIcons}; + TypedArray arrayType = obtainStyledAttributes(arrayAttr); + int arrayId = arrayType.getResourceId(0, 0); + TypedArray iconType = getResources().obtainTypedArray(arrayId); + for(int i = 0; i < drawerItemsList.size(); i++) { + drawerItemsIconsList.add(iconType.getResourceId(i, 0)); + drawerItemsVisibleList.add(true); + } + iconType.recycle(); + arrayType.recycle(); + + // Hide listings user doesn't want to see + if(!podcastsEnabled) { + drawerItemsVisibleList.set(3, false); + } + if(!bookmarksEnabled) { + drawerItemsVisibleList.set(4, false); + } + if(!sharedEnabled) { + drawerItemsVisibleList.set(5, false); + } + if(!chatEnabled) { + drawerItemsVisibleList.set(6, false); + } + if(!adminEnabled) { + drawerItemsVisibleList.set(7, false); + } + if(!getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD_VIEW)) { + drawerItemsVisibleList.set(8, false); + } + + drawerList.setAdapter(drawerAdapter = new DrawerAdapter(this, drawerItemsList, drawerItemsIconsList, drawerItemsVisibleList)); + enabledItems[0] = podcastsEnabled; + enabledItems[1] = bookmarksEnabled; + enabledItems[2] = sharedEnabled; + enabledItems[3] = chatEnabled; + enabledItems[4] = adminEnabled; + + String fragmentType = getIntent().getStringExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE); + if(fragmentType != null && lastSelectedPosition == 0) { + for(int i = 0; i < drawerItemsDescriptions.length; i++) { + if(fragmentType.equals(drawerItemsDescriptions[i])) { + lastSelectedPosition = drawerAdapter.getAdapterPosition(i); + break; + } + } + } + + if(drawerList.getChildAt(lastSelectedPosition) == null) { + lastSelectedView = null; + drawerAdapter.setSelectedPosition(lastSelectedPosition); + } else { + lastSelectedView = (TextView) drawerList.getChildAt(lastSelectedPosition).findViewById(R.id.drawer_name); + if(lastSelectedView != null) { + lastSelectedView.setTextAppearance(SubsonicActivity.this, R.style.DSub_TextViewStyle_Bold); + } + } + } + } + + private void drawerItemSelected(int position, View view) { + startFragmentActivity(drawerItemsDescriptions[position]); + + if(lastSelectedView != view) { + if(lastSelectedView != null) { + lastSelectedView.setTextAppearance(this, R.style.DSub_TextViewStyle); + } + + lastSelectedView = (TextView) view.findViewById(R.id.drawer_name); + lastSelectedView.setTextAppearance(this, R.style.DSub_TextViewStyle_Bold); + lastSelectedPosition = position; + } + } + + public void startFragmentActivity(String fragmentType) { + Intent intent = new Intent(); + intent.setClass(SubsonicActivity.this, SubsonicFragmentActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + if(!"".equals(fragmentType)) { + intent.putExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE, fragmentType); + } + startActivity(intent); + finish(); + } + + protected void exit() { + if(((Object) this).getClass() != SubsonicFragmentActivity.class) { + Intent intent = new Intent(this, SubsonicFragmentActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(Constants.INTENT_EXTRA_NAME_EXIT, true); + Util.startActivityWithoutTransition(this, intent); + } else { + finished = true; + this.stopService(new Intent(this, DownloadService.class)); + this.finish(); + } + } + + public boolean onBackPressedSupport() { + if(drawerOpen) { + drawer.closeDrawers(); + return false; + } else if(backStack.size() > 0) { + removeCurrent(); + return false; + } else { + return true; + } + } + + @Override + public void onBackPressed() { + if(onBackPressedSupport()) { + super.onBackPressed(); + } + } + + public void replaceFragment(SubsonicFragment fragment, int tag) { + replaceFragment(fragment, tag, false); + } + public void replaceFragment(SubsonicFragment fragment, int tag, boolean replaceCurrent) { + SubsonicFragment oldFragment = currentFragment; + if(currentFragment != null) { + currentFragment.setPrimaryFragment(false, secondaryContainer != null); + } + backStack.add(currentFragment); + + currentFragment = fragment; + currentFragment.setPrimaryFragment(true); + supportInvalidateOptionsMenu(); + + if(secondaryContainer == null) { + FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); + trans.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + trans.hide(oldFragment); + trans.add(R.id.fragment_container, fragment, tag + ""); + trans.commit(); + } else { + // Make sure secondary container is visible now + secondaryContainer.setVisibility(View.VISIBLE); + + FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); + + // Check to see if you need to put on top of old left or not + if(backStack.size() > 1) { + // Move old right to left if there is a backstack already + SubsonicFragment newLeftFragment = backStack.get(backStack.size() - 1); + if(replaceCurrent) { + // trans.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + } + trans.remove(newLeftFragment); + + // Only move right to left if replaceCurrent is false + if(!replaceCurrent) { + SubsonicFragment oldLeftFragment = backStack.get(backStack.size() - 2); + oldLeftFragment.setSecondaryFragment(false); + // trans.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + trans.hide(oldLeftFragment); + + // Make sure remove is finished before adding + trans.commit(); + getSupportFragmentManager().executePendingTransactions(); + + trans = getSupportFragmentManager().beginTransaction(); + // trans.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + trans.add(R.id.fragment_container, newLeftFragment, newLeftFragment.getSupportTag() + ""); + } else { + backStack.remove(backStack.size() - 1); + } + } + + // Add fragment to the right container + trans.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right); + trans.add(R.id.fragment_second_container, fragment, tag + ""); + + // Commit it all + trans.commit(); + } + recreateSpinner(); + } + public void removeCurrent() { + if(currentFragment != null) { + currentFragment.setPrimaryFragment(false); + } + Fragment oldFrag = currentFragment; + + currentFragment = backStack.remove(backStack.size() - 1); + currentFragment.setPrimaryFragment(true, false); + supportInvalidateOptionsMenu(); + + if(secondaryContainer == null) { + FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); + trans.setCustomAnimations(R.anim.enter_from_left, R.anim.exit_to_right, R.anim.enter_from_right, R.anim.exit_to_left); + trans.remove(oldFrag); + trans.show(currentFragment); + trans.commit(); + } else { + FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); + + // Remove old right fragment + trans.setCustomAnimations(R.anim.enter_from_left, R.anim.exit_to_right, R.anim.enter_from_right, R.anim.exit_to_left); + trans.remove(oldFrag); + + // Only switch places if there is a backstack, otherwise primary container is correct + if(backStack.size() > 0) { + trans.setCustomAnimations(0, 0, 0, 0); + // Add current left fragment to right side + trans.remove(currentFragment); + + // Make sure remove is finished before adding + trans.commit(); + getSupportFragmentManager().executePendingTransactions(); + + trans = getSupportFragmentManager().beginTransaction(); + // trans.setCustomAnimations(R.anim.enter_from_left, R.anim.exit_to_right, R.anim.enter_from_right, R.anim.exit_to_left); + trans.add(R.id.fragment_second_container, currentFragment, currentFragment.getSupportTag() + ""); + + SubsonicFragment newLeftFragment = backStack.get(backStack.size() - 1); + newLeftFragment.setSecondaryFragment(true); + trans.show(newLeftFragment); + } else { + secondaryContainer.startAnimation(AnimationUtils.loadAnimation(this, R.anim.exit_to_right)); + secondaryContainer.setVisibility(View.GONE); + } + + trans.commit(); + } + recreateSpinner(); + } + + public void invalidate() { + if(currentFragment != null) { + while(backStack.size() > 0) { + removeCurrent(); + } + + currentFragment.invalidate(); + populateDrawer(); + } + + supportInvalidateOptionsMenu(); + } + + protected void recreateSpinner() { + if(currentFragment == null || currentFragment.getTitle() == null) { + return; + } + + if(backStack.size() > 0) { + spinnerAdapter.clear(); + for(int i = 0; i < backStack.size(); i++) { + CharSequence title = backStack.get(i).getTitle(); + if(title != null) { + spinnerAdapter.add(title); + } else { + spinnerAdapter.add("null"); + } + } + if(currentFragment.getTitle() != null) { + spinnerAdapter.add(currentFragment.getTitle()); + } else { + spinnerAdapter.add("null"); + } + spinnerAdapter.notifyDataSetChanged(); + actionBarSpinner.setSelection(spinnerAdapter.getCount() - 1); + if(!isTv()) { + getSupportActionBar().setDisplayShowCustomEnabled(true); + } + } else if(!isTv()) { + getSupportActionBar().setDisplayShowCustomEnabled(false); + } + } + + protected void restart() { + Intent intent = new Intent(this, ((Object) this).getClass()); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtras(getIntent()); + Util.startActivityWithoutTransition(this, intent); + } + + private void applyTheme() { + theme = Util.getTheme(this); + + if(theme != null && theme.indexOf("fullscreen") != -1) { + theme = theme.substring(0, theme.indexOf("_fullscreen")); + Util.setTheme(this, theme); + } + + Util.applyTheme(this, theme); + } + private void applyFullscreen() { + fullScreen = Util.getPreferences(this).getBoolean(Constants.PREFERENCES_KEY_FULL_SCREEN, false); + if(fullScreen || isTv()) { + // Hide additional elements on higher Android versions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + int flags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + + getWindow().getDecorView().setSystemUiVisibility(flags); + } else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + getWindow().requestFeature(Window.FEATURE_NO_TITLE); + } + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + } + + public boolean isDestroyedCompat() { + return destroyed; + } + + public synchronized ImageLoader getImageLoader() { + if (IMAGE_LOADER == null) { + IMAGE_LOADER = new ImageLoader(this); + } + return IMAGE_LOADER; + } + public synchronized static ImageLoader getStaticImageLoader(Context context) { + if (IMAGE_LOADER == null) { + IMAGE_LOADER = new ImageLoader(context); + } + return IMAGE_LOADER; + } + + public DownloadService getDownloadService() { + if(finished) { + return null; + } + + // If service is not available, request it to start and wait for it. + for (int i = 0; i < 5; i++) { + DownloadService downloadService = DownloadService.getInstance(); + if (downloadService != null) { + return downloadService; + } + Log.w(TAG, "DownloadService not running. Attempting to start it."); + startService(new Intent(this, DownloadService.class)); + Util.sleepQuietly(50L); + } + return DownloadService.getInstance(); + } + + public static String getThemeName() { + return theme; + } + + public boolean isTv() { + return tv; + } + public boolean isTouchscreen() { + return touchscreen; + } + + private void setUncaughtExceptionHandler() { + Thread.UncaughtExceptionHandler handler = Thread.getDefaultUncaughtExceptionHandler(); + if (!(handler instanceof SubsonicActivity.SubsonicUncaughtExceptionHandler)) { + Thread.setDefaultUncaughtExceptionHandler(new SubsonicActivity.SubsonicUncaughtExceptionHandler(this)); + } + } + + /** + * Logs the stack trace of uncaught exceptions to a file on the SD card. + */ + private static class SubsonicUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { + + private final Thread.UncaughtExceptionHandler defaultHandler; + private final Context context; + + private SubsonicUncaughtExceptionHandler(Context context) { + this.context = context; + defaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + } + + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + File file = null; + PrintWriter printWriter = null; + try { + + PackageInfo packageInfo = context.getPackageManager().getPackageInfo("github.daneren2005.dsub", 0); + file = new File(Environment.getExternalStorageDirectory(), "dsub-stacktrace.txt"); + printWriter = new PrintWriter(file); + printWriter.println("Android API level: " + Build.VERSION.SDK); + printWriter.println("Subsonic version name: " + packageInfo.versionName); + printWriter.println("Subsonic version code: " + packageInfo.versionCode); + printWriter.println(); + throwable.printStackTrace(printWriter); + Log.i(TAG, "Stack trace written to " + file); + } catch (Throwable x) { + Log.e(TAG, "Failed to write stack trace to " + file, x); + } finally { + Util.close(printWriter); + if (defaultHandler != null) { + defaultHandler.uncaughtException(thread, throwable); + } + + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/activity/SubsonicFragmentActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/SubsonicFragmentActivity.java new file mode 100644 index 00000000..6614e09d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/SubsonicFragmentActivity.java @@ -0,0 +1,686 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.activity; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.Dialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.support.v4.app.FragmentTransaction; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.fragments.AdminFragment; +import github.daneren2005.dsub.fragments.ChatFragment; +import github.daneren2005.dsub.fragments.DownloadFragment; +import github.daneren2005.dsub.fragments.MainFragment; +import github.daneren2005.dsub.fragments.SearchFragment; +import github.daneren2005.dsub.fragments.SelectArtistFragment; +import github.daneren2005.dsub.fragments.SelectBookmarkFragment; +import github.daneren2005.dsub.fragments.SelectDirectoryFragment; +import github.daneren2005.dsub.fragments.SelectPlaylistFragment; +import github.daneren2005.dsub.fragments.SelectPodcastsFragment; +import github.daneren2005.dsub.fragments.SelectShareFragment; +import github.daneren2005.dsub.fragments.SubsonicFragment; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.updates.Updater; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.view.ChangeLog; + +/** + * Created by Scott on 10/14/13. + */ +public class SubsonicFragmentActivity extends SubsonicActivity { + private static String TAG = SubsonicFragmentActivity.class.getSimpleName(); + private static boolean infoDialogDisplayed; + private static boolean sessionInitialized = false; + private static long ALLOWED_SKEW = 30000L; + + private ScheduledExecutorService executorService; + private View bottomBar; + private View coverArtView; + private TextView trackView; + private TextView artistView; + private ImageButton startButton; + private long lastBackPressTime = 0; + private DownloadFile currentPlaying; + private PlayerState currentState; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_EXIT)) { + stopService(new Intent(this, DownloadService.class)); + finish(); + getImageLoader().clearCache(); + } else if(getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD_VIEW)) { + getIntent().putExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE, "Download"); + if(drawerAdapter != null) { + drawerAdapter.setDownloadVisible(true); + } + } else if(getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD)) { + DownloadService service = getDownloadService(); + if((service != null && service.getCurrentPlaying() != null)) { + getIntent().removeExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD); + Intent intent = new Intent(); + intent.setClass(this, DownloadActivity.class); + startActivity(intent); + } + } + setContentView(R.layout.abstract_fragment_activity); + + UserUtil.seedCurrentUser(this); + if (findViewById(R.id.fragment_container) != null && savedInstanceState == null) { + String fragmentType = getIntent().getStringExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE); + boolean firstRun = false; + if(fragmentType == null) { + fragmentType = Util.openToTab(this); + if(fragmentType != null) { + getIntent().putExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE, fragmentType); + firstRun = true; + } + } + currentFragment = getNewFragment(fragmentType); + + if("".equals(fragmentType) || fragmentType == null || firstRun) { + // Initial startup stuff + if(!sessionInitialized) { + loadSession(); + } + } + + currentFragment.setPrimaryFragment(true); + getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, currentFragment, currentFragment.getSupportTag() + "").commit(); + + if(getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_QUERY) != null) { + SearchFragment fragment = new SearchFragment(); + replaceFragment(fragment, fragment.getSupportTag()); + } + + // If a album type is set, switch to that album type view + String albumType = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE); + if(albumType != null) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, albumType); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 20); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0); + + fragment.setArguments(args); + replaceFragment(fragment, fragment.getSupportTag()); + } + } + + bottomBar = findViewById(R.id.bottom_bar); + bottomBar.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + Intent intent = new Intent(); + intent.setClass(v.getContext(), DownloadActivity.class); + startActivity(intent); + } + }); + coverArtView = bottomBar.findViewById(R.id.album_art); + trackView = (TextView) bottomBar.findViewById(R.id.track_name); + artistView = (TextView) bottomBar.findViewById(R.id.artist_name); + + ImageButton previousButton = (ImageButton) findViewById(R.id.download_previous); + previousButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + new SilentBackgroundTask(SubsonicFragmentActivity.this) { + @Override + protected Void doInBackground() throws Throwable { + if(getDownloadService() == null) { + return null; + } + + getDownloadService().previous(); + return null; + } + + @Override + protected void done(Void result) { + update(); + } + }.execute(); + } + }); + + startButton = (ImageButton) findViewById(R.id.download_start); + startButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + new SilentBackgroundTask(SubsonicFragmentActivity.this) { + @Override + protected Void doInBackground() throws Throwable { + PlayerState state = getDownloadService().getPlayerState(); + if(state == PlayerState.STARTED) { + getDownloadService().pause(); + } else { + getDownloadService().start(); + } + + return null; + } + + @Override + protected void done(Void result) { + update(); + } + }.execute(); + } + }); + + ImageButton nextButton = (ImageButton) findViewById(R.id.download_next); + nextButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + new SilentBackgroundTask(SubsonicFragmentActivity.this) { + @Override + protected Void doInBackground() throws Throwable { + if(getDownloadService() == null) { + return null; + } + + getDownloadService().next(); + return null; + } + + @Override + protected void done(Void result) { + update(); + } + }.execute(); + } + }); + } + + @Override + protected void onPostCreate(Bundle bundle) { + super.onPostCreate(bundle); + + showInfoDialog(); + checkUpdates(); + + ChangeLog changeLog = new ChangeLog(this, Util.getPreferences(this)); + if(changeLog.isFirstRun()) { + if(changeLog.isFirstRunEver()) { + changeLog.updateVersionInPreferences(); + } else { + Dialog log = changeLog.getLogDialog(); + if (log != null) { + log.show(); + } + } + } + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + + if(currentFragment != null && intent.getStringExtra(Constants.INTENT_EXTRA_NAME_QUERY) != null) { + if(currentFragment instanceof SearchFragment) { + String query = intent.getStringExtra(Constants.INTENT_EXTRA_NAME_QUERY); + boolean autoplay = intent.getBooleanExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false); + boolean requestsearch = intent.getBooleanExtra(Constants.INTENT_EXTRA_REQUEST_SEARCH, false); + + if (query != null) { + ((SearchFragment)currentFragment).search(query, autoplay); + } else { + ((SearchFragment)currentFragment).populateList(); + if (requestsearch) { + onSearchRequested(); + } + } + getIntent().removeExtra(Constants.INTENT_EXTRA_NAME_QUERY); + } else { + setIntent(intent); + + SearchFragment fragment = new SearchFragment(); + replaceFragment(fragment, fragment.getSupportTag()); + } + } else { + setIntent(intent); + } + if(drawer != null) { + drawer.closeDrawers(); + } + } + + @Override + public void onResume() { + super.onResume(); + + final Handler handler = new Handler(); + Runnable runnable = new Runnable() { + @Override + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + update(); + } + }); + } + }; + + if(getIntent().hasExtra(Constants.INTENT_EXTRA_VIEW_ALBUM)) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ID)); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_NAME)); + args.putString(Constants.INTENT_EXTRA_SEARCH_SONG, getIntent().getStringExtra(Constants.INTENT_EXTRA_SEARCH_SONG)); + if(getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_ARTIST)) { + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + } + if(getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_CHILD_ID)) { + args.putString(Constants.INTENT_EXTRA_NAME_CHILD_ID, getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_CHILD_ID)); + } + fragment.setArguments(args); + + replaceFragment(fragment, fragment.getSupportTag()); + getIntent().removeExtra(Constants.INTENT_EXTRA_VIEW_ALBUM); + if("Artist".equals(getIntent().getStringExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE))) { + lastSelectedPosition = 1; + } + } + + createAccount(); + + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0L, 1000L, TimeUnit.MILLISECONDS); + } + + @Override + public void onPause() { + super.onPause(); + executorService.shutdown(); + } + + @Override + public void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if(drawerToggle != null && backStack.size() > 0) { + drawerToggle.setDrawerIndicatorEnabled(false); + } + } + + @Override + public void setContentView(int viewId) { + super.setContentView(viewId); + if(drawerToggle != null){ + drawerToggle.setDrawerIndicatorEnabled(true); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + if(onBackPressedSupport()) { + if(!Util.disableExitPrompt(this) && lastBackPressTime < (System.currentTimeMillis() - 4000)) { + lastBackPressTime = System.currentTimeMillis(); + Util.toast(this, R.string.main_back_confirm); + } else { + finish(); + } + } + } + + @Override + public void replaceFragment(SubsonicFragment fragment, int tag, boolean replaceCurrent) { + super.replaceFragment(fragment, tag, replaceCurrent); + if(drawerToggle != null) { + drawerToggle.setDrawerIndicatorEnabled(false); + } + } + @Override + public void removeCurrent() { + super.removeCurrent(); + if(drawerToggle != null && backStack.isEmpty()) { + drawerToggle.setDrawerIndicatorEnabled(true); + } + } + + @Override + public void startFragmentActivity(String fragmentType) { + // Create a transaction that does all of this + FragmentTransaction trans = getSupportFragmentManager().beginTransaction(); + + // Clear existing stack + for(int i = backStack.size() - 1; i >= 0; i--) { + trans.remove(backStack.get(i)); + } + trans.remove(currentFragment); + backStack.clear(); + + // Create new stack + currentFragment = getNewFragment(fragmentType); + currentFragment.setPrimaryFragment(true); + trans.add(R.id.fragment_container, currentFragment, currentFragment.getSupportTag() + ""); + + // Done, cleanup + trans.commit(); + supportInvalidateOptionsMenu(); + recreateSpinner(); + if(drawer != null) { + drawer.closeDrawers(); + } + + if(secondaryContainer != null) { + secondaryContainer.setVisibility(View.GONE); + } + if(drawerToggle != null) { + drawerToggle.setDrawerIndicatorEnabled(true); + } + } + + private SubsonicFragment getNewFragment(String fragmentType) { + if("Artist".equals(fragmentType)) { + return new SelectArtistFragment(); + } else if("Playlist".equals(fragmentType)) { + return new SelectPlaylistFragment(); + } else if("Chat".equals(fragmentType)) { + return new ChatFragment(); + } else if("Podcast".equals(fragmentType)) { + return new SelectPodcastsFragment(); + } else if("Bookmark".equals(fragmentType)) { + return new SelectBookmarkFragment(); + } else if("Share".equals(fragmentType)) { + return new SelectShareFragment(); + } else if("Admin".equals(fragmentType)) { + return new AdminFragment(); + } else if("Download".equals(fragmentType)) { + return new DownloadFragment(); + } else { + return new MainFragment(); + } + } + + private void update() { + DownloadService downloadService = getDownloadService(); + if (downloadService == null) { + return; + } + + DownloadFile current = downloadService.getCurrentPlaying(); + PlayerState state = downloadService.getPlayerState(); + if(current == currentPlaying && state == currentState) { + return; + } else { + currentPlaying = current; + currentState = state; + } + + MusicDirectory.Entry song = null; + if(current != null) { + song = current.getSong(); + trackView.setText(song.getTitle()); + artistView.setText(song.getArtist()); + } else { + trackView.setText("Title"); + artistView.setText("Artist"); + } + + getImageLoader().loadImage(coverArtView, song, false, false); + int[] attrs = new int[] {(state == PlayerState.STARTED) ? R.attr.media_button_pause : R.attr.media_button_start}; + TypedArray typedArray = this.obtainStyledAttributes(attrs); + startButton.setImageResource(typedArray.getResourceId(0, 0)); + typedArray.recycle(); + } + + public void checkUpdates() { + try { + String version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; + int ver = Integer.parseInt(version.replace(".", "")); + Updater updater = new Updater(ver); + updater.checkUpdates(this); + } + catch(Exception e) { + + } + } + + private void loadSession() { + loadSettings(); + if(!Util.isOffline(this) && ServerInfo.canBookmark(this)) { + loadBookmarks(); + } + // If we are on Subsonic 5.2+, save play queue + if(ServerInfo.canSavePlayQueue(this) && !Util.isOffline(this)) { + loadRemotePlayQueue(); + } + + sessionInitialized = true; + } + private void loadSettings() { + PreferenceManager.setDefaultValues(this, R.xml.settings, false); + SharedPreferences prefs = Util.getPreferences(this); + if (!prefs.contains(Constants.PREFERENCES_KEY_CACHE_LOCATION) || prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null) == null) { + resetCacheLocation(prefs); + } else { + String path = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + File cacheLocation = new File(path); + if(!FileUtil.verifyCanWrite(cacheLocation)) { + // Only warn user if there is a difference saved + if(resetCacheLocation(prefs)) { + Util.info(this, R.string.common_warning, R.string.settings_cache_location_reset); + } + } + } + + if (!prefs.contains(Constants.PREFERENCES_KEY_OFFLINE)) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_OFFLINE, false); + + editor.putString(Constants.PREFERENCES_KEY_SERVER_NAME + 1, "Demo Server"); + editor.putString(Constants.PREFERENCES_KEY_SERVER_URL + 1, "http://demo.subsonic.org"); + editor.putString(Constants.PREFERENCES_KEY_USERNAME + 1, "android-guest"); + editor.putString(Constants.PREFERENCES_KEY_PASSWORD + 1, "guest"); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + editor.commit(); + } + if(!prefs.contains(Constants.PREFERENCES_KEY_SERVER_COUNT)) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_COUNT, 1); + editor.commit(); + } + } + + private boolean resetCacheLocation(SharedPreferences prefs) { + String newDirectory = FileUtil.getDefaultMusicDirectory(this).getPath(); + String oldDirectory = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + if(newDirectory == null || (oldDirectory != null && newDirectory.equals(oldDirectory))) { + return false; + } else { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_CACHE_LOCATION, newDirectory); + editor.commit(); + return true; + } + } + + private void loadBookmarks() { + final Context context = this; + new SilentBackgroundTask(context) { + @Override + public Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.getBookmarks(true, context, null); + + return null; + } + + @Override + public void error(Throwable error) { + Log.e(TAG, "Failed to get bookmarks", error); + } + }.execute(); + } + private void loadRemotePlayQueue() { + final SubsonicActivity context = this; + new SilentBackgroundTask(this) { + private PlayerQueue playerQueue; + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(context); + PlayerQueue remoteState = musicService.getPlayQueue(context, null); + + // Make sure we wait until download service is ready + DownloadService downloadService = getDownloadService(); + while(downloadService == null || !downloadService.isInitialized()) { + Util.sleepQuietly(100L); + downloadService = getDownloadService(); + } + + // If we had a remote state and it's changed is more recent than our existing state + if(remoteState != null && remoteState.changed != null) { + // Check if changed + 30 seconds since some servers have slight skew + Date remoteChange = new Date(remoteState.changed.getTime() - ALLOWED_SKEW); + Date localChange = downloadService.getLastStateChanged(); + if(localChange == null || localChange.before(remoteChange)) { + playerQueue = remoteState; + } + } + } catch (Exception e) { + Log.e(TAG, "Failed to get playing queue to server", e); + } + + return null; + } + + @Override + protected void done(Void arg) { + if(!context.isDestroyedCompat() && playerQueue != null) { + promptRestoreFromRemoteQueue(playerQueue); + } + } + }.execute(); + } + private void promptRestoreFromRemoteQueue(final PlayerQueue remoteState) { + Util.confirmDialog(this, R.string.download_restore_play_queue, Util.formatDate(remoteState.changed), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new SilentBackgroundTask(SubsonicFragmentActivity.this) { + @Override + protected Void doInBackground() throws Throwable { + DownloadService downloadService = getDownloadService(); + downloadService.clear(); + downloadService.download(remoteState.songs, false, false, false, false, remoteState.currentPlayingIndex, remoteState.currentPlayingPosition); + return null; + } + }.execute(); + } + }, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new SilentBackgroundTask(SubsonicFragmentActivity.this) { + @Override + protected Void doInBackground() throws Throwable { + DownloadService downloadService = getDownloadService(); + downloadService.serializeQueue(false); + return null; + } + }.execute(); + } + }); + } + + private void createAccount() { + final Context context = this; + + new SilentBackgroundTask(this) { + @Override + protected Void doInBackground() throws Throwable { + AccountManager accountManager = (AccountManager) context.getSystemService(ACCOUNT_SERVICE); + Account account = new Account(Constants.SYNC_ACCOUNT_NAME, Constants.SYNC_ACCOUNT_TYPE); + accountManager.addAccountExplicitly(account, null, null); + + SharedPreferences prefs = Util.getPreferences(context); + boolean syncEnabled = prefs.getBoolean(Constants.PREFERENCES_KEY_SYNC_ENABLED, true); + int syncInterval = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_SYNC_INTERVAL, "60")); + + // Add enabled/frequency to playlist/podcasts syncing + ContentResolver.setSyncAutomatically(account, Constants.SYNC_ACCOUNT_PLAYLIST_AUTHORITY, syncEnabled); + ContentResolver.addPeriodicSync(account, Constants.SYNC_ACCOUNT_PLAYLIST_AUTHORITY, new Bundle(), 60L * syncInterval); + ContentResolver.setSyncAutomatically(account, Constants.SYNC_ACCOUNT_PODCAST_AUTHORITY, syncEnabled); + ContentResolver.addPeriodicSync(account, Constants.SYNC_ACCOUNT_PODCAST_AUTHORITY, new Bundle(), 60L * syncInterval); + + // Add for starred/recently added + ContentResolver.setSyncAutomatically(account, Constants.SYNC_ACCOUNT_STARRED_AUTHORITY, (syncEnabled && prefs.getBoolean(Constants.PREFERENCES_KEY_SYNC_STARRED, false))); + ContentResolver.addPeriodicSync(account, Constants.SYNC_ACCOUNT_STARRED_AUTHORITY, new Bundle(), 60L * syncInterval); + ContentResolver.setSyncAutomatically(account, Constants.SYNC_ACCOUNT_MOST_RECENT_AUTHORITY, (syncEnabled && prefs.getBoolean(Constants.PREFERENCES_KEY_SYNC_MOST_RECENT, false))); + ContentResolver.addPeriodicSync(account, Constants.SYNC_ACCOUNT_MOST_RECENT_AUTHORITY, new Bundle(), 60L * syncInterval); + return null; + } + + @Override + protected void done(Void result) { + + } + }.execute(); + } + + private void showInfoDialog() { + if (!infoDialogDisplayed) { + infoDialogDisplayed = true; + if (Util.getRestUrl(this, null).contains("demo.subsonic.org")) { + Util.info(this, R.string.main_welcome_title, R.string.main_welcome_text); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/activity/VoiceQueryReceiverActivity.java b/app/src/main/java/github/daneren2005/dsub/activity/VoiceQueryReceiverActivity.java new file mode 100644 index 00000000..7ae0ba77 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/activity/VoiceQueryReceiverActivity.java @@ -0,0 +1,61 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.activity; + +import android.app.Activity; +import android.app.SearchManager; +import android.content.Intent; +import android.os.Bundle; +import android.provider.SearchRecentSuggestions; + +import github.daneren2005.dsub.fragments.SubsonicFragment; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.provider.DSubSearchProvider; + +/** + * Receives voice search queries and forwards to the SearchFragment. + * + * http://android-developers.blogspot.com/2010/09/supporting-new-music-voice-action.html + * + * @author Sindre Mehus + */ +public class VoiceQueryReceiverActivity extends Activity { + private static String GMS_SEARCH_ACTION = "com.google.android.gms.actions.SEARCH_ACTION"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String query = getIntent().getStringExtra(SearchManager.QUERY); + + if (query != null) { + Intent intent = new Intent(VoiceQueryReceiverActivity.this, SubsonicFragmentActivity.class); + intent.putExtra(Constants.INTENT_EXTRA_NAME_QUERY, query); + if(!GMS_SEARCH_ACTION.equals(getIntent().getAction())) { + intent.putExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true); + } + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + Util.startActivityWithoutTransition(VoiceQueryReceiverActivity.this, intent); + } + finish(); + Util.disablePendingTransition(this); + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/AlbumGridAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/AlbumGridAdapter.java new file mode 100644 index 00000000..eb187569 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/AlbumGridAdapter.java @@ -0,0 +1,73 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import java.util.List; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.view.AlbumCell; + +public class AlbumGridAdapter extends ArrayAdapter { + private final static String TAG = AlbumGridAdapter.class.getSimpleName(); + private final Context activity; + private final ImageLoader imageLoader; + private List entries; + private boolean showArtist; + + public AlbumGridAdapter(Context activity, ImageLoader imageLoader, List entries, boolean showArtist) { + super(activity, android.R.layout.simple_list_item_1, entries); + this.entries = entries; + this.activity = activity; + this.imageLoader = imageLoader; + + // Always show artist if they aren't all the same + if(!showArtist) { + String artist = null; + for(MusicDirectory.Entry entry: entries) { + if(artist == null) { + artist = entry.getArtist(); + } + + if(artist != null && !artist.equals(entry.getArtist())) { + showArtist = true; + } + } + } + this.showArtist = showArtist; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + MusicDirectory.Entry entry = getItem(position); + + AlbumCell view; + if(convertView instanceof AlbumCell) { + view = (AlbumCell) convertView; + } else { + view = new AlbumCell(activity); + } + + view.setShowArtist(showArtist); + view.setObject(entry, imageLoader); + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/AlbumListAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/AlbumListAdapter.java new file mode 100644 index 00000000..b2fcded3 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/AlbumListAdapter.java @@ -0,0 +1,154 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.SectionIndexer; + +import com.commonsware.cwac.endless.EndlessAdapter; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public class AlbumListAdapter extends EndlessAdapter implements SectionIndexer { + Context context; + ArrayAdapter adapter; + String type; + String extra; + int size; + int offset; + List entries; + + private boolean shouldIndex = false; + private Object[] sections; + private Integer[] positions; + + public AlbumListAdapter(Context context, ArrayAdapter adapter, String type, String extra, int size) { + super(adapter); + this.context = context; + this.adapter = adapter; + this.type = type; + this.extra = extra; + this.size = size; + this.offset = size; + + if("alphabeticalByName".equals(this.type)) { + shouldIndex = true; + recreateIndexes(); + } + } + + @Override + protected boolean cacheInBackground() throws Exception { + MusicService service = MusicServiceFactory.getMusicService(context); + MusicDirectory result; + if(("genres".equals(type) && ServerInfo.checkServerVersion(context, "1.10.0")) || "years".equals(type)) { + result = service.getAlbumList(type, extra, size, offset, context, null); + } else if("genres".equals(type) || "genres-songs".equals(type)) { + result = service.getSongsByGenre(extra, size, offset, context, null); + } else { + result = service.getAlbumList(type, size, offset, context, null); + } + entries = result.getChildren(); + return entries.size() > 0; + } + + @Override + protected void appendCachedData() { + for(MusicDirectory.Entry entry: entries) { + adapter.add(entry); + } + offset += entries.size(); + recreateIndexes(); + } + + @Override + protected View getPendingView(ViewGroup parent) { + View progress = LayoutInflater.from(context).inflate(R.layout.tab_progress, null); + progress.setVisibility(View.VISIBLE); + return progress; + } + + private void recreateIndexes() { + if(!shouldIndex) { + return; + } + + Set sectionSet = new LinkedHashSet(30); + List positionList = new ArrayList(30); + for (int i = 0; i < adapter.getCount(); i++) { + MusicDirectory.Entry entry = adapter.getItem(i); + String index = entry.getAlbum().substring(0, 1); + if(!Character.isLetter(index.charAt(0))) { + index = "#"; + } + + if (!sectionSet.contains(index)) { + sectionSet.add(index); + positionList.add(i); + } + } + sections = sectionSet.toArray(new Object[sectionSet.size()]); + positions = positionList.toArray(new Integer[positionList.size()]); + } + + @Override + public Object[] getSections() { + if(sections != null) { + return sections; + } else { + return new Object[0]; + } + } + + @Override + public int getPositionForSection(int section) { + if(sections != null) { + section = Math.min(section, positions.length - 1); + return positions[section]; + } else { + return 0; + } + } + + @Override + public int getSectionForPosition(int pos) { + if(sections != null) { + for (int i = 0; i < sections.length - 1; i++) { + if (pos < positions[i + 1]) { + return i; + } + } + return sections.length - 1; + } else { + return 0; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/ArtistAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/ArtistAdapter.java new file mode 100644 index 00000000..4d469faf --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/ArtistAdapter.java @@ -0,0 +1,97 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import github.daneren2005.dsub.R; +import java.util.List; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.SectionIndexer; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.view.ArtistView; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * @author Sindre Mehus + */ +public class ArtistAdapter extends ArrayAdapter implements SectionIndexer { + + private final Context activity; + + // Both arrays are indexed by section ID. + private final Object[] sections; + private final Integer[] positions; + + public ArtistAdapter(Context activity, List artists) { + super(activity, R.layout.basic_list_item, artists); + this.activity = activity; + + Set sectionSet = new LinkedHashSet(30); + List positionList = new ArrayList(30); + for (int i = 0; i < artists.size(); i++) { + Artist artist = artists.get(i); + String index = artist.getIndex(); + if (!sectionSet.contains(index)) { + sectionSet.add(index); + positionList.add(i); + } + } + sections = sectionSet.toArray(new Object[sectionSet.size()]); + positions = positionList.toArray(new Integer[positionList.size()]); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Artist entry = getItem(position); + ArtistView view; + if (convertView != null && convertView instanceof ArtistView) { + view = (ArtistView) convertView; + } else { + view = new ArtistView(activity); + } + view.setObject(entry); + return view; + } + + @Override + public Object[] getSections() { + return sections; + } + + @Override + public int getPositionForSection(int section) { + section = Math.min(section, positions.length - 1); + return positions[section]; + } + + @Override + public int getSectionForPosition(int pos) { + for (int i = 0; i < sections.length - 1; i++) { + if (pos < positions[i + 1]) { + return i; + } + } + return sections.length - 1; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/BookmarkAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/BookmarkAdapter.java new file mode 100644 index 00000000..26d3e16a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/BookmarkAdapter.java @@ -0,0 +1,64 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus +*/ + +package github.daneren2005.dsub.adapter; + +import android.content.Context; + +import java.util.List; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.view.SongView; + +public class BookmarkAdapter extends ArrayAdapter { + private final static String TAG = BookmarkAdapter.class.getSimpleName(); + private Context activity; + + public BookmarkAdapter(Context activity, List bookmarks) { + super(activity, android.R.layout.simple_list_item_1, bookmarks); + this.activity = activity; + } + + public View getView(int position, View convertView, ViewGroup parent) { + MusicDirectory.Entry entry = getItem(position); + Bookmark bookmark = entry.getBookmark(); + + SongView view; + if (convertView != null) { + view = (SongView) convertView; + } else { + view = new SongView(activity); + } + view.setObject(entry, false); + + // Add current position to duration + TextView durationTextView = (TextView) view.findViewById(R.id.song_duration); + String duration = durationTextView.getText().toString(); + durationTextView.setText(Util.formatDuration(bookmark.getPosition() / 1000) + " / " + duration); + + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/ChatAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/ChatAdapter.java new file mode 100644 index 00000000..0c116d39 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/ChatAdapter.java @@ -0,0 +1,109 @@ +package github.daneren2005.dsub.adapter; + +import android.text.method.LinkMovementMethod; +import android.text.util.Linkify; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.domain.ChatMessage; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.regex.Pattern; + +public class ChatAdapter extends ArrayAdapter { + + private final SubsonicActivity activity; + private ArrayList messages; + private final ImageLoader imageLoader; + + private static final String phoneRegex = "1?\\W*([2-9][0-8][0-9])\\W*([2-9][0-9]{2})\\W*([0-9]{4})"; //you can just place your support phone here + private static final Pattern phoneMatcher = Pattern.compile(phoneRegex); + + public ChatAdapter(SubsonicActivity activity, ArrayList messages, ImageLoader imageLoader) { + super(activity, R.layout.chat_item, messages); + this.activity = activity; + this.messages = messages; + this.imageLoader = imageLoader; + } + + @Override + public int getCount() { + return messages.size(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ChatMessage message = this.getItem(position); + + ViewHolder holder; + int layout; + + String messageUser = message.getUsername(); + Date messageTime = new java.util.Date(message.getTime()); + String messageText = message.getMessage(); + + String me = UserUtil.getCurrentUsername(activity); + + if (messageUser.equals(me)) { + layout = R.layout.chat_item_reverse; + } else { + layout = R.layout.chat_item; + } + + if (convertView == null) + { + holder = new ViewHolder(); + + convertView = LayoutInflater.from(activity).inflate(layout, parent, false); + + TextView usernameView = (TextView) convertView.findViewById(R.id.chat_username); + TextView timeView = (TextView) convertView.findViewById(R.id.chat_time); + TextView messageView = (TextView) convertView.findViewById(R.id.chat_message); + + messageView.setMovementMethod(LinkMovementMethod.getInstance()); + Linkify.addLinks(messageView, Linkify.EMAIL_ADDRESSES); + Linkify.addLinks(messageView, Linkify.WEB_URLS); + Linkify.addLinks(messageView, phoneMatcher, "tel:"); + + holder.message = messageView; + holder.username = usernameView; + holder.time = timeView; + holder.avatar = (ImageView) convertView.findViewById(R.id.chat_avatar); + + convertView.setTag(holder); + } + else + { + holder = (ViewHolder) convertView.getTag(); + } + + DateFormat timeFormat = android.text.format.DateFormat.getTimeFormat(activity); + String messageTimeFormatted = String.format("[%s]", timeFormat.format(messageTime)); + + holder.username.setText(messageUser); + holder.message.setText(messageText); + holder.time.setText(messageTimeFormatted); + + imageLoader.loadAvatar(activity, holder.avatar, messageUser); + + return convertView; + } + + private static class ViewHolder + { + TextView message; + TextView username; + TextView time; + ImageView avatar; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/DownloadFileAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/DownloadFileAdapter.java new file mode 100644 index 00000000..be9b4cb9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/DownloadFileAdapter.java @@ -0,0 +1,49 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import java.util.List; + +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.view.SongView; + +public class DownloadFileAdapter extends ArrayAdapter { + Context context; + + public DownloadFileAdapter(Context context, List entries) { + super(context, android.R.layout.simple_list_item_1, entries); + this.context = context; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + SongView view; + if (convertView != null && convertView instanceof SongView) { + view = (SongView) convertView; + } else { + view = new SongView(context); + } + DownloadFile downloadFile = getItem(position); + view.setObject(downloadFile.getSong(), false); + view.setDownloadFile(downloadFile); + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/DrawerAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/DrawerAdapter.java new file mode 100644 index 00000000..b0a4a33d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/DrawerAdapter.java @@ -0,0 +1,126 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus +*/ +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.List; + +import github.daneren2005.dsub.R; + +/** + * Created by Scott on 11/8/13. + */ +public class DrawerAdapter extends ArrayAdapter { + private static String TAG = DrawerAdapter.class.getSimpleName(); + private Context context; + private List items; + private List icons; + private List visible; + private int selectedPosition = -1; + + public DrawerAdapter(Context context, List items, List icons, List visible) { + super(context, R.layout.drawer_list_item, items); + + this.context = context; + this.items = items; + this.icons = icons; + this.visible = visible; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + position = getActualPosition(position); + String item = items.get(position); + Integer icon = icons.get(position); + + if(convertView == null) { + convertView = LayoutInflater.from(context).inflate(R.layout.drawer_list_item, null); + } + + TextView textView = (TextView) convertView.findViewById(R.id.drawer_name); + textView.setText(item); + + if(selectedPosition == position) { + textView.setTextAppearance(context, R.style.DSub_TextViewStyle_Bold); + selectedPosition = -1; + } + + ImageView iconView = (ImageView) convertView.findViewById(R.id.drawer_icon); + iconView.setImageResource(icon); + + return convertView; + } + + @Override + public int getCount() { + int count = 0; + for(int i = 0; i < visible.size(); i++) { + if(visible.get(i)) { + count++; + } + } + + return count; + } + + public int getActualPosition(int position) { + for(int i = 0; i <= position; i++) { + if(!visible.get(i)) { + position++; + } + } + + return position; + } + public int getAdapterPosition(int position) { + if(!visible.get(position)) { + visible.set(position, true); + notifyDataSetChanged(); + } + + for(int i = position; i >= 0; i--) { + if(!visible.get(i)) { + position--; + } + } + + return position; + } + + public void setItemVisible(int position, boolean visible) { + if(this.visible.get(position) != visible) { + this.visible.set(position, visible); + notifyDataSetInvalidated(); + } + } + public void setDownloadVisible(boolean visible) { + setItemVisible(items.size() - 2, visible); + } + + public void setSelectedPosition(int position) { + selectedPosition = position; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/EntryAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/EntryAdapter.java new file mode 100644 index 00000000..9e506e5a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/EntryAdapter.java @@ -0,0 +1,82 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.content.Context; + +import java.util.List; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.view.AlbumView; +import github.daneren2005.dsub.view.ArtistEntryView; +import github.daneren2005.dsub.view.SongView; + +/** + * @author Sindre Mehus + */ +public class EntryAdapter extends ArrayAdapter { + private final static String TAG = EntryAdapter.class.getSimpleName(); + private final Context activity; + private final ImageLoader imageLoader; + private final boolean checkable; + private List entries; + + public EntryAdapter(Context activity, ImageLoader imageLoader, List entries, boolean checkable) { + super(activity, android.R.layout.simple_list_item_1, entries); + this.entries = entries; + this.activity = activity; + this.imageLoader = imageLoader; + this.checkable = checkable; + } + + public void removeAt(int position) { + entries.remove(position); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + MusicDirectory.Entry entry = getItem(position); + + if (entry.isDirectory()) { + if(entry.isAlbum()) { + AlbumView view; + view = new AlbumView(activity); + view.setObject(entry, imageLoader); + return view; + } else { + ArtistEntryView view = new ArtistEntryView(activity); + view.setObject(entry); + return view; + } + } else { + SongView view; + if (convertView != null && convertView instanceof SongView) { + view = (SongView) convertView; + } else { + view = new SongView(activity); + } + view.setObject(entry, checkable); + return view; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/GenreAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/GenreAdapter.java new file mode 100644 index 00000000..abb208c9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/GenreAdapter.java @@ -0,0 +1,60 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.widget.ArrayAdapter; +import android.widget.SectionIndexer; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.view.GenreView; + +import java.util.List; +import java.util.Set; +import java.util.LinkedHashSet; +import java.util.ArrayList; + +/** + * @author Sindre Mehus +*/ +public class GenreAdapter extends ArrayAdapter{ + private Context activity; + private List genres; + + public GenreAdapter(Context context, List genres) { + super(context, android.R.layout.simple_list_item_1, genres); + this.activity = context; + this.genres = genres; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Genre genre = genres.get(position); + GenreView view; + if (convertView != null && convertView instanceof GenreView) { + view = (GenreView) convertView; + } else { + view = new GenreView(activity); + } + view.setObject(genre); + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/MergeAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/MergeAdapter.java new file mode 100644 index 00000000..a2db4cf0 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/MergeAdapter.java @@ -0,0 +1,290 @@ +/*** + Copyright (c) 2008-2009 CommonsWare, LLC + Portions (c) 2009 Google, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain + a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +package github.daneren2005.dsub.adapter; + +import android.database.DataSetObserver; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ListAdapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Arrays; + +/** + * Adapter that merges multiple child adapters and views + * into a single contiguous whole. + *

+ * Adapters used as pieces within MergeAdapter must + * have view type IDs monotonically increasing from 0. Ideally, + * adapters also have distinct ranges for their row ids, as + * returned by getItemId(). + */ +public class MergeAdapter extends BaseAdapter { + + private final CascadeDataSetObserver observer = new CascadeDataSetObserver(); + private final ArrayList pieces = new ArrayList(); + + /** + * Stock constructor, simply chaining to the superclass. + */ + public MergeAdapter() { + super(); + } + + /** + * Adds a new adapter to the roster of things to appear + * in the aggregate list. + * + * @param adapter Source for row views for this section + */ + public void addAdapter(ListAdapter adapter) { + pieces.add(adapter); + adapter.registerDataSetObserver(observer); + } + + public void removeAdapter(ListAdapter adapter) { + adapter.unregisterDataSetObserver(observer); + pieces.remove(adapter); + } + + /** + * Adds a new View to the roster of things to appear + * in the aggregate list. + * + * @param view Single view to add + */ + public ListAdapter addView(View view) { + return addView(view, false); + } + + /** + * Adds a new View to the roster of things to appear + * in the aggregate list. + * + * @param view Single view to add + * @param enabled false if views are disabled, true if enabled + */ + public ListAdapter addView(View view, boolean enabled) { + return addViews(Arrays.asList(view), enabled); + } + + /** + * Adds a list of views to the roster of things to appear + * in the aggregate list. + * + * @param views List of views to add + */ + public ListAdapter addViews(List views) { + return addViews(views, false); + } + + /** + * Adds a list of views to the roster of things to appear + * in the aggregate list. + * + * @param views List of views to add + * @param enabled false if views are disabled, true if enabled + */ + public ListAdapter addViews(List views, boolean enabled) { + ListAdapter adapter = enabled ? new EnabledSackAdapter(views) : new SackOfViewsAdapter(views); + addAdapter(adapter); + return adapter; + } + + /** + * Get the data item associated with the specified + * position in the data set. + * + * @param position Position of the item whose data we want + */ + @Override + public Object getItem(int position) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + return (piece.getItem(position)); + } + + position -= size; + } + + return (null); + } + + /** + * How many items are in the data set represented by this + * Adapter. + */ + @Override + public int getCount() { + int total = 0; + + for (ListAdapter piece : pieces) { + total += piece.getCount(); + } + + return (total); + } + + /** + * Returns the number of types of Views that will be + * created by getView(). + */ + @Override + public int getViewTypeCount() { + int total = 0; + + for (ListAdapter piece : pieces) { + total += piece.getViewTypeCount(); + } + + return (Math.max(total, 1)); // needed for setListAdapter() before content add' + } + + /** + * Get the type of View that will be created by getView() + * for the specified item. + * + * @param position Position of the item whose data we want + */ + @Override + public int getItemViewType(int position) { + int typeOffset = 0; + int result = -1; + + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + result = typeOffset + piece.getItemViewType(position); + break; + } + + position -= size; + typeOffset += piece.getViewTypeCount(); + } + + return (result); + } + + /** + * Are all items in this ListAdapter enabled? If yes it + * means all items are selectable and clickable. + */ + @Override + public boolean areAllItemsEnabled() { + return (false); + } + + /** + * Returns true if the item at the specified position is + * not a separator. + * + * @param position Position of the item whose data we want + */ + @Override + public boolean isEnabled(int position) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + return (piece.isEnabled(position)); + } + + position -= size; + } + + return (false); + } + + /** + * Get a View that displays the data at the specified + * position in the data set. + * + * @param position Position of the item whose data we want + * @param convertView View to recycle, if not null + * @param parent ViewGroup containing the returned View + */ + @Override + public View getView(int position, View convertView, + ViewGroup parent) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + + return (piece.getView(position, convertView, parent)); + } + + position -= size; + } + + return (null); + } + + /** + * Get the row id associated with the specified position + * in the list. + * + * @param position Position of the item whose data we want + */ + @Override + public long getItemId(int position) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + return (piece.getItemId(position)); + } + + position -= size; + } + + return (-1); + } + + private static class EnabledSackAdapter extends SackOfViewsAdapter { + public EnabledSackAdapter(List views) { + super(views); + } + + @Override + public boolean areAllItemsEnabled() { + return (true); + } + + @Override + public boolean isEnabled(int position) { + return (true); + } + } + + private class CascadeDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + notifyDataSetInvalidated(); + } + } +} + diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/PlaylistAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/PlaylistAdapter.java new file mode 100644 index 00000000..d56a6b97 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/PlaylistAdapter.java @@ -0,0 +1,70 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import github.daneren2005.dsub.R; +import java.util.List; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.view.PlaylistView; + +import java.util.Collections; +import java.util.Comparator; + +/** + * @author Sindre Mehus + */ +public class PlaylistAdapter extends ArrayAdapter { + + private final Context activity; + + public PlaylistAdapter(Context activity, List Playlists) { + super(activity, R.layout.basic_list_item, Playlists); + this.activity = activity; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Playlist entry = getItem(position); + PlaylistView view; + if (convertView != null && convertView instanceof PlaylistView) { + view = (PlaylistView) convertView; + } else { + view = new PlaylistView(activity); + } + view.setObject(entry); + return view; + } + + public static class PlaylistComparator implements Comparator { + @Override + public int compare(Playlist playlist1, Playlist playlist2) { + return playlist1.getName().compareToIgnoreCase(playlist2.getName()); + } + + public static List sort(List playlists) { + Collections.sort(playlists, new PlaylistComparator()); + return playlists; + } + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/PodcastChannelAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/PodcastChannelAdapter.java new file mode 100644 index 00000000..8ee39a10 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/PodcastChannelAdapter.java @@ -0,0 +1,60 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.widget.ArrayAdapter; +import android.widget.SectionIndexer; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.view.PodcastChannelView; + +import java.util.List; +import java.util.Set; +import java.util.LinkedHashSet; +import java.util.ArrayList; + +/** + * @author Sindre Mehus +*/ +public class PodcastChannelAdapter extends ArrayAdapter{ + private Context activity; + private List podcasts; + + public PodcastChannelAdapter(Context context, List podcasts) { + super(context, android.R.layout.simple_list_item_1, podcasts); + this.activity = context; + this.podcasts = podcasts; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + PodcastChannel podcast = podcasts.get(position); + PodcastChannelView view; + if (convertView != null && convertView instanceof PodcastChannelView) { + view = (PodcastChannelView) convertView; + } else { + view = new PodcastChannelView(activity); + } + view.setObject(podcast); + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/SackOfViewsAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/SackOfViewsAdapter.java new file mode 100644 index 00000000..e4744cc5 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/SackOfViewsAdapter.java @@ -0,0 +1,181 @@ +/*** + Copyright (c) 2008-2009 CommonsWare, LLC + Portions (c) 2009 Google, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain + a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package github.daneren2005.dsub.adapter; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ListView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Adapter that simply returns row views from a list. + *

+ * If you supply a size, you must implement newView(), to + * create a required view. The adapter will then cache these + * views. + *

+ * If you supply a list of views in the constructor, that + * list will be used directly. If any elements in the list + * are null, then newView() will be called just for those + * slots. + *

+ * Subclasses may also wish to override areAllItemsEnabled() + * (default: false) and isEnabled() (default: false), if some + * of their rows should be selectable. + *

+ * It is assumed each view is unique, and therefore will not + * get recycled. + *

+ * Note that this adapter is not designed for long lists. It + * is more for screens that should behave like a list. This + * is particularly useful if you combine this with other + * adapters (e.g., SectionedAdapter) that might have an + * arbitrary number of rows, so it all appears seamless. + */ +public class SackOfViewsAdapter extends BaseAdapter { + private List views = null; + + /** + * Constructor creating an empty list of views, but with + * a specified count. Subclasses must override newView(). + */ + public SackOfViewsAdapter(int count) { + super(); + + views = new ArrayList(count); + + for (int i = 0; i < count; i++) { + views.add(null); + } + } + + /** + * Constructor wrapping a supplied list of views. + * Subclasses must override newView() if any of the elements + * in the list are null. + */ + public SackOfViewsAdapter(List views) { + for (View view : views) { + view.setLayoutParams(new ListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + this.views = views; + } + + /** + * Get the data item associated with the specified + * position in the data set. + * + * @param position Position of the item whose data we want + */ + @Override + public Object getItem(int position) { + return (views.get(position)); + } + + /** + * How many items are in the data set represented by this + * Adapter. + */ + @Override + public int getCount() { + return (views.size()); + } + + /** + * Returns the number of types of Views that will be + * created by getView(). + */ + @Override + public int getViewTypeCount() { + return (getCount()); + } + + /** + * Get the type of View that will be created by getView() + * for the specified item. + * + * @param position Position of the item whose data we want + */ + @Override + public int getItemViewType(int position) { + return (position); + } + + /** + * Are all items in this ListAdapter enabled? If yes it + * means all items are selectable and clickable. + */ + @Override + public boolean areAllItemsEnabled() { + return (false); + } + + /** + * Returns true if the item at the specified position is + * not a separator. + * + * @param position Position of the item whose data we want + */ + @Override + public boolean isEnabled(int position) { + return (false); + } + + /** + * Get a View that displays the data at the specified + * position in the data set. + * + * @param position Position of the item whose data we want + * @param convertView View to recycle, if not null + * @param parent ViewGroup containing the returned View + */ + @Override + public View getView(int position, View convertView, + ViewGroup parent) { + View result = views.get(position); + + if (result == null) { + result = newView(position, parent); + views.set(position, result); + } + + return (result); + } + + /** + * Get the row id associated with the specified position + * in the list. + * + * @param position Position of the item whose data we want + */ + @Override + public long getItemId(int position) { + return (position); + } + + /** + * Create a new View to go into the list at the specified + * position. + * + * @param position Position of the item whose data we want + * @param parent ViewGroup containing the returned View + */ + protected View newView(int position, ViewGroup parent) { + throw new RuntimeException("You must override newView()!"); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/SettingsAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/SettingsAdapter.java new file mode 100644 index 00000000..45c3ead1 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/SettingsAdapter.java @@ -0,0 +1,59 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.view.SettingView; + +import static github.daneren2005.dsub.domain.User.Setting; + +public class SettingsAdapter extends ArrayAdapter { + private final Context context; + private final boolean editable; + + public SettingsAdapter(Context context, User user, boolean editable) { + super(context, R.layout.basic_list_item, user.getSettings()); + this.context = context; + this.editable = editable; + } + + public SettingsAdapter(Context context, List settings, boolean editable) { + super(context, R.layout.basic_list_item, settings); + this.context = context; + this.editable = editable; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Setting entry = getItem(position); + SettingView view; + if (convertView != null && convertView instanceof SettingView) { + view = (SettingView) convertView; + } else { + view = new SettingView(context); + } + view.setObject(entry, editable); + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/ShareAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/ShareAdapter.java new file mode 100644 index 00000000..4121a85a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/ShareAdapter.java @@ -0,0 +1,56 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import java.util.List; + +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.view.ShareView; + +/** + * @author Sindre Mehus +*/ +public class ShareAdapter extends ArrayAdapter{ + private Context activity; + private List shares; + + public ShareAdapter(Context context, List shares) { + super(context, android.R.layout.simple_list_item_1, shares); + this.activity = context; + this.shares = shares; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Share share = shares.get(position); + ShareView view; + if (convertView != null && convertView instanceof ShareView) { + view = (ShareView) convertView; + } else { + view = new ShareView(activity); + } + view.setObject(share); + return view; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/adapter/UserAdapter.java b/app/src/main/java/github/daneren2005/dsub/adapter/UserAdapter.java new file mode 100644 index 00000000..f0f78d97 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/adapter/UserAdapter.java @@ -0,0 +1,52 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.adapter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.view.UserView; + +public class UserAdapter extends ArrayAdapter { + private final Context activity; + private final ImageLoader imageLoader; + + public UserAdapter(Context activity, List users, ImageLoader imageLoader) { + super(activity, R.layout.basic_list_item, users); + this.activity = activity; + this.imageLoader = imageLoader; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + User entry = getItem(position); + UserView view; + if (convertView != null && convertView instanceof UserView) { + view = (UserView) convertView; + } else { + view = new UserView(activity); + } + view.setObject(entry, imageLoader); + return view; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/audiofx/AudioEffectsController.java b/app/src/main/java/github/daneren2005/dsub/audiofx/AudioEffectsController.java new file mode 100644 index 00000000..1933bd64 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/audiofx/AudioEffectsController.java @@ -0,0 +1,69 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2014 (C) Scott Jackson +*/ +package github.daneren2005.dsub.audiofx; + +import android.content.Context; +import android.media.MediaPlayer; +import android.media.audiofx.AudioEffect; +import android.media.audiofx.LoudnessEnhancer; +import android.os.Build; +import android.util.Log; + +public class AudioEffectsController { + private static final String TAG = AudioEffectsController.class.getSimpleName(); + + private final Context context; + private int audioSessionId = 0; + + private boolean available = false; + + private EqualizerController equalizerController; + + public AudioEffectsController(Context context, int audioSessionId) { + this.context = context; + this.audioSessionId = audioSessionId; + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { + available = true; + } + } + + public boolean isAvailable() { + return available; + } + + public void release() { + if(equalizerController != null) { + equalizerController.release(); + } + } + + public EqualizerController getEqualizerController() { + if (available && equalizerController == null) { + equalizerController = new EqualizerController(context, audioSessionId); + if (!equalizerController.isAvailable()) { + equalizerController = null; + } else { + equalizerController.loadSettings(); + } + } + return equalizerController; + } +} + diff --git a/app/src/main/java/github/daneren2005/dsub/audiofx/EqualizerController.java b/app/src/main/java/github/daneren2005/dsub/audiofx/EqualizerController.java new file mode 100644 index 00000000..f170af0b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/audiofx/EqualizerController.java @@ -0,0 +1,198 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2011 (C) Sindre Mehus + */ +package github.daneren2005.dsub.audiofx; + +import java.io.Serializable; + +import android.content.Context; +import android.media.audiofx.BassBoost; +import android.media.audiofx.Equalizer; +import android.os.Build; +import android.util.Log; +import github.daneren2005.dsub.util.FileUtil; + +/** + * Backward-compatible wrapper for {@link Equalizer}, which is API Level 9. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class EqualizerController { + + private static final String TAG = EqualizerController.class.getSimpleName(); + + private final Context context; + private Equalizer equalizer; + private BassBoost bass; + private boolean loudnessAvailable = false; + private LoudnessEnhancerController loudnessEnhancerController; + private boolean released = false; + private int audioSessionId = 0; + + public EqualizerController(Context context, int audioSessionId) { + this.context = context; + this.audioSessionId = audioSessionId; + init(); + } + + private void init() { + equalizer = new Equalizer(0, audioSessionId); + bass = new BassBoost(0, audioSessionId); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + loudnessAvailable = true; + loudnessEnhancerController = new LoudnessEnhancerController(context, audioSessionId); + } + } + + public void saveSettings() { + try { + if (isAvailable()) { + FileUtil.serialize(context, new EqualizerSettings(equalizer, bass, loudnessEnhancerController), "equalizer.dat"); + } + } catch (Throwable x) { + Log.w(TAG, "Failed to save equalizer settings.", x); + } + } + + public void loadSettings() { + try { + if (isAvailable()) { + EqualizerSettings settings = FileUtil.deserialize(context, "equalizer.dat", EqualizerSettings.class); + if (settings != null) { + settings.apply(equalizer, bass, loudnessEnhancerController); + } + } + } catch (Throwable x) { + Log.w(TAG, "Failed to load equalizer settings.", x); + } + } + + public boolean isAvailable() { + return equalizer != null && bass != null; + } + + public boolean isEnabled() { + try { + return isAvailable() && equalizer.getEnabled(); + } catch(Exception e) { + return false; + } + } + + public void release() { + if (isAvailable()) { + released = true; + equalizer.release(); + bass.release(); + if(loudnessEnhancerController != null && loudnessEnhancerController.isAvailable()) { + loudnessEnhancerController.release(); + } + } + } + + public Equalizer getEqualizer() { + if(released) { + released = false; + try { + init(); + } catch (Throwable x) { + equalizer = null; + released = true; + Log.w(TAG, "Failed to create equalizer.", x); + } + } + return equalizer; + } + public BassBoost getBassBoost() { + if(released) { + released = false; + try { + init(); + } catch (Throwable x) { + bass = null; + Log.w(TAG, "Failed to create bass booster.", x); + } + } + return bass; + } + public LoudnessEnhancerController getLoudnessEnhancerController() { + if(loudnessAvailable && released) { + released = false; + try { + init(); + } catch (Throwable x) { + loudnessEnhancerController = null; + Log.w(TAG, "Failed to create loudness enhancer.", x); + } + } + return loudnessEnhancerController; + } + + private static class EqualizerSettings implements Serializable { + + private short[] bandLevels; + private short preset; + private boolean enabled; + private short bass; + private int loudness; + + public EqualizerSettings() { + + } + public EqualizerSettings(Equalizer equalizer, BassBoost boost, LoudnessEnhancerController loudnessEnhancerController) { + enabled = equalizer.getEnabled(); + bandLevels = new short[equalizer.getNumberOfBands()]; + for (short i = 0; i < equalizer.getNumberOfBands(); i++) { + bandLevels[i] = equalizer.getBandLevel(i); + } + try { + preset = equalizer.getCurrentPreset(); + } catch (Exception x) { + preset = -1; + } + try { + bass = boost.getRoundedStrength(); + } catch(Exception e) { + bass = 0; + } + + try { + loudness = (int) loudnessEnhancerController.getGain(); + } catch(Exception e) { + loudness = 0; + } + } + + public void apply(Equalizer equalizer, BassBoost boost, LoudnessEnhancerController loudnessController) { + for (short i = 0; i < bandLevels.length; i++) { + equalizer.setBandLevel(i, bandLevels[i]); + } + equalizer.setEnabled(enabled); + if(bass != 0) { + boost.setEnabled(true); + boost.setStrength(bass); + } + if(loudness != 0) { + loudnessController.enable(); + loudnessController.setGain(loudness); + } + } + } +} + diff --git a/app/src/main/java/github/daneren2005/dsub/audiofx/LoudnessEnhancerController.java b/app/src/main/java/github/daneren2005/dsub/audiofx/LoudnessEnhancerController.java new file mode 100644 index 00000000..df6fdb1c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/audiofx/LoudnessEnhancerController.java @@ -0,0 +1,77 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2014 (C) Scott Jackson +*/ +package github.daneren2005.dsub.audiofx; + +import android.content.Context; +import android.media.audiofx.LoudnessEnhancer; +import android.util.Log; + +public class LoudnessEnhancerController { + private static final String TAG = LoudnessEnhancerController.class.getSimpleName(); + + private final Context context; + private LoudnessEnhancer enhancer; + private boolean released = false; + private int audioSessionId = 0; + + public LoudnessEnhancerController(Context context, int audioSessionId) { + this.context = context; + try { + this.audioSessionId = audioSessionId; + enhancer = new LoudnessEnhancer(audioSessionId); + } catch (Throwable x) { + Log.w(TAG, "Failed to create enhancer", x); + } + } + + public boolean isAvailable() { + return enhancer != null; + } + + public boolean isEnabled() { + try { + return isAvailable() && enhancer.getEnabled(); + } catch(Exception e) { + return false; + } + } + + public void enable() { + enhancer.setEnabled(true); + } + public void disable() { + enhancer.setEnabled(false); + } + + public float getGain() { + return enhancer.getTargetGain(); + } + public void setGain(int gain) { + enhancer.setTargetGain(gain); + } + + public void release() { + if (isAvailable()) { + enhancer.release(); + released = true; + } + } + +} + diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Artist.java b/app/src/main/java/github/daneren2005/dsub/domain/Artist.java new file mode 100644 index 00000000..f30147e6 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Artist.java @@ -0,0 +1,145 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import android.util.Log; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * @author Sindre Mehus + */ +public class Artist implements Serializable { + private static final String TAG = Artist.class.getSimpleName(); + + private String id; + private String name; + private String index; + private boolean starred; + private int closeness; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getIndex() { + return index; + } + + public void setIndex(String index) { + this.index = index; + } + + public boolean isStarred() { + return starred; + } + + public void setStarred(boolean starred) { + this.starred = starred; + } + + public int getCloseness() { + return closeness; + } + + public void setCloseness(int closeness) { + this.closeness = closeness; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Artist entry = (Artist) o; + return id.equals(entry.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return name; + } + + public static class ArtistComparator implements Comparator { + private String[] ignoredArticles; + + public ArtistComparator(String[] ignoredArticles) { + this.ignoredArticles = ignoredArticles; + } + + public int compare(Artist lhsArtist, Artist rhsArtist) { + String lhs = lhsArtist.getName().toLowerCase(); + String rhs = rhsArtist.getName().toLowerCase(); + + char lhs1 = lhs.charAt(0); + char rhs1 = rhs.charAt(0); + + if (Character.isDigit(lhs1) && !Character.isDigit(rhs1)) { + return 1; + } else if (Character.isDigit(rhs1) && !Character.isDigit(lhs1)) { + return -1; + } + + for (String article : ignoredArticles) { + int index = lhs.indexOf(article.toLowerCase() + " "); + if (index == 0) { + lhs = lhs.substring(article.length() + 1); + } + index = rhs.indexOf(article.toLowerCase() + " "); + if (index == 0) { + rhs = rhs.substring(article.length() + 1); + } + } + + return lhs.compareTo(rhs); + } + } + + public static void sort(List artists, String[] ignoredArticles) { + try { + Collections.sort(artists, new ArtistComparator(ignoredArticles)); + } catch (Exception e) { + Log.w(TAG, "Failed to sort artists", e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/domain/ArtistInfo.java b/app/src/main/java/github/daneren2005/dsub/domain/ArtistInfo.java new file mode 100644 index 00000000..2205d561 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/ArtistInfo.java @@ -0,0 +1,76 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.domain; + +import java.io.Serializable; +import java.util.List; + +public class ArtistInfo implements Serializable { + private String biography; + private String musicBrainzId; + private String lastFMUrl; + private String imageUrl; + private List similarArtists; + private List missingArtists; + + public String getBiography() { + return biography; + } + + public void setBiography(String biography) { + this.biography = biography; + } + + public String getMusicBrainzId() { + return musicBrainzId; + } + + public void setMusicBrainzId(String musicBrainzId) { + this.musicBrainzId = musicBrainzId; + } + + public String getLastFMUrl() { + return lastFMUrl; + } + + public void setLastFMUrl(String lastFMUrl) { + this.lastFMUrl = lastFMUrl; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public List getSimilarArtists() { + return similarArtists; + } + + public void setSimilarArtists(List similarArtists) { + this.similarArtists = similarArtists; + } + + public List getMissingArtists() { + return missingArtists; + } + + public void setMissingArtists(List missingArtists) { + this.missingArtists = missingArtists; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Bookmark.java b/app/src/main/java/github/daneren2005/dsub/domain/Bookmark.java new file mode 100644 index 00000000..3c0c5835 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Bookmark.java @@ -0,0 +1,105 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2013 (C) Scott Jackson + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Created by Scott on 11/4/13. + */ +public class Bookmark implements Serializable { + private int position; + private String username; + private String comment; + private Date created; + private Date changed; + + public Bookmark() { + + } + public Bookmark(int position) { + this.position = position; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public Date getCreated() { + return created; + } + + public void setCreated(String created) { + if (created != null) { + try { + this.created = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).parse(created); + } catch (ParseException e) { + this.created = null; + } + } else { + this.created = null; + } + } + public void setCreated(Date created) { + this.created = created; + } + + public Date getChanged() { + return changed; + } + + public void setChanged(String changed) { + if (changed != null) { + try { + this.changed = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).parse(changed); + } catch (ParseException e) { + this.changed = null; + } + } else { + this.changed = null; + } + } + public void setChanged(Date changed) { + this.changed = changed; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/ChatMessage.java b/app/src/main/java/github/daneren2005/dsub/domain/ChatMessage.java new file mode 100644 index 00000000..04b9effc --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/ChatMessage.java @@ -0,0 +1,51 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; + +public class ChatMessage implements Serializable { + private String username; + private Long time; + private String message; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Long getTime() { + return time; + } + + public void setTime(Long time) { + this.time = time; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/DLNADevice.java b/app/src/main/java/github/daneren2005/dsub/domain/DLNADevice.java new file mode 100644 index 00000000..d50a398a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/DLNADevice.java @@ -0,0 +1,78 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.domain; + +import android.os.Parcel; +import android.os.Parcelable; + +import org.fourthline.cling.model.meta.Device; + +/** + * Created by Scott on 11/1/2014. + */ +public class DLNADevice implements Parcelable { + public Device renderer; + public String id; + public String name; + public String description; + public int volume; + public int volumeMax; + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public DLNADevice createFromParcel(Parcel in) { + return new DLNADevice(in); + } + + public DLNADevice[] newArray(int size) { + return new DLNADevice[size]; + } + }; + + private DLNADevice(Parcel in) { + id = in.readString(); + name = in.readString(); + description = in.readString(); + volume = in.readInt(); + volumeMax = in.readInt(); + } + + public DLNADevice(Device renderer, String id, String name, String description, int volume, int volumeMax) { + this.renderer = renderer; + this.id = id; + this.name = name; + this.description = description; + this.volume = volume; + this.volumeMax = volumeMax; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(name); + dest.writeString(description); + dest.writeInt(volume); + dest.writeInt(volumeMax); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Genre.java b/app/src/main/java/github/daneren2005/dsub/domain/Genre.java new file mode 100644 index 00000000..4b6ac344 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Genre.java @@ -0,0 +1,69 @@ +package github.daneren2005.dsub.domain; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +public class Genre implements Serializable { + private String name; + private String index; + private Integer albumCount; + private Integer songCount; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getIndex() { + return index; + } + + public void setIndex(String index) { + this.index = index; + } + + @Override + public String toString() { + return name; + } + + public Integer getAlbumCount() { + return albumCount; + } + + public void setAlbumCount(Integer albumCount) { + this.albumCount = albumCount; + } + + public Integer getSongCount() { + return songCount; + } + + public void setSongCount(Integer songCount) { + this.songCount = songCount; + } + + public static class GenreComparator implements Comparator { + @Override + public int compare(Genre genre1, Genre genre2) { + return genre1.getName().compareToIgnoreCase(genre2.getName()); + } + + public static List sort(List genres) { + Collections.sort(genres, new GenreComparator()); + return genres; + } + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Indexes.java b/app/src/main/java/github/daneren2005/dsub/domain/Indexes.java new file mode 100644 index 00000000..e15ccf9f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Indexes.java @@ -0,0 +1,94 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.ArrayList; +import java.util.List; +import java.io.Serializable; + +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + */ +public class Indexes implements Serializable { + + private long lastModified; + private List shortcuts; + private List artists; + private List entries; + + public Indexes() { + + } + public Indexes(long lastModified, List shortcuts, List artists) { + this.lastModified = lastModified; + this.shortcuts = shortcuts; + this.artists = artists; + this.entries = new ArrayList(); + } + public Indexes(long lastModified, List shortcuts, List artists, List entries) { + this.lastModified = lastModified; + this.shortcuts = shortcuts; + this.artists = artists; + this.entries = entries; + if(!entries.isEmpty()) { + Artist root = new Artist(); + root.setId("root"); + root.setName("Root"); + root.setIndex("#"); + artists.add(root); + } + } + + public long getLastModified() { + return lastModified; + } + + public List getShortcuts() { + return shortcuts; + } + + public List getArtists() { + return artists; + } + + public void setArtists(List artists) { + this.shortcuts = new ArrayList(); + this.artists.clear(); + this.artists.addAll(artists); + } + + public List getEntries() { + return entries; + } + + public void sortChildren(Context context) { + SharedPreferences prefs = Util.getPreferences(context); + String ignoredArticlesString = prefs.getString(Constants.CACHE_KEY_IGNORE, "The El La Los Las Le Les"); + final String[] ignoredArticles = ignoredArticlesString.split(" "); + + Artist.sort(shortcuts, ignoredArticles); + Artist.sort(artists, ignoredArticles); + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Lyrics.java b/app/src/main/java/github/daneren2005/dsub/domain/Lyrics.java new file mode 100644 index 00000000..5272920d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Lyrics.java @@ -0,0 +1,57 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; + +/** + * Song lyrics. + * + * @author Sindre Mehus + */ +public class Lyrics implements Serializable { + + private String artist; + private String title; + private String text; + + public String getArtist() { + return artist; + } + + public void setArtist(String artist) { + this.artist = artist; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/MusicDirectory.java b/app/src/main/java/github/daneren2005/dsub/domain/MusicDirectory.java new file mode 100644 index 00000000..ad819763 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/MusicDirectory.java @@ -0,0 +1,559 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import android.content.Context; +import android.media.MediaMetadataRetriever; +import android.util.Log; +import java.util.ArrayList; +import java.util.List; +import java.io.File; +import java.io.Serializable; +import java.util.Collections; +import java.util.Comparator; + +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + */ +public class MusicDirectory implements Serializable { + private static final String TAG = MusicDirectory.class.getSimpleName(); + + private String name; + private String id; + private String parent; + private List children; + + public MusicDirectory() { + children = new ArrayList(); + } + public MusicDirectory(List children) { + this.children = children; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getParent() { + return parent; + } + + public void setParent(String parent) { + this.parent = parent; + } + + public void addChild(Entry child) { + if(child != null) { + children.add(child); + } + } + public void addChildren(List children) { + this.children.addAll(children); + } + + public void replaceChildren(List children) { + this.children = children; + } + + public List getChildren() { + return getChildren(true, true); + } + + public List getChildren(boolean includeDirs, boolean includeFiles) { + if (includeDirs && includeFiles) { + return children; + } + + List result = new ArrayList(children.size()); + for (Entry child : children) { + if (child != null && child.isDirectory() && includeDirs || !child.isDirectory() && includeFiles) { + result.add(child); + } + } + return result; + } + + public int getChildrenSize() { + return children.size(); + } + + public void shuffleChildren() { + Collections.shuffle(this.children); + } + + public void sortChildren(Context context, int instance) { + if(ServerInfo.checkServerVersion(context, "1.8", instance)) { + sortChildren(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_CUSTOM_SORT_ENABLED, true)); + } + } + public void sortChildren(boolean byYear) { + EntryComparator.sort(children, byYear); + } + + public static class Entry implements Serializable { + public static final int TYPE_SONG = 0; + public static final int TYPE_PODCAST = 1; + public static final int TYPE_AUDIO_BOOK = 2; + + private String id; + private String parent; + private String grandParent; + private String albumId; + private String artistId; + private boolean directory; + private String title; + private String album; + private String artist; + private Integer track; + private Integer year; + private String genre; + private String contentType; + private String suffix; + private String transcodedContentType; + private String transcodedSuffix; + private String coverArt; + private Long size; + private Integer duration; + private Integer bitRate; + private String path; + private boolean video; + private Integer discNumber; + private boolean starred; + private Integer rating; + private Bookmark bookmark; + private int type = 0; + private int closeness; + + public Entry() { + + } + public Entry(String id) { + this.id = id; + } + public Entry(Artist artist) { + this.id = artist.getId(); + this.title = artist.getName(); + this.directory = true; + } + + public void loadMetadata(File file) { + try { + MediaMetadataRetriever metadata = new MediaMetadataRetriever(); + metadata.setDataSource(file.getAbsolutePath()); + String discNumber = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER); + if(discNumber == null) { + discNumber = "1/1"; + } + int slashIndex = discNumber.indexOf("/"); + if(slashIndex > 0) { + discNumber = discNumber.substring(0, slashIndex); + } + try { + setDiscNumber(Integer.parseInt(discNumber)); + } catch(Exception e) { + Log.w(TAG, "Non numbers in disc field!"); + } + String bitrate = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE); + setBitRate(Integer.parseInt((bitrate != null) ? bitrate : "0") / 1000); + String length = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + setDuration(Integer.parseInt(length) / 1000); + String artist = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST); + if(artist != null) { + setArtist(artist); + } + String album = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM); + if(album != null) { + setAlbum(album); + } + metadata.release(); + } catch(Exception e) { + Log.i(TAG, "Device doesn't properly support MediaMetadataRetreiver", e); + } + } + public void rebaseTitleOffPath() { + try { + String filename = getPath(); + int index = filename.lastIndexOf('/'); + if (index != -1) { + filename = filename.substring(index + 1); + if (getTrack() != null) { + filename = filename.replace(String.format("%02d ", getTrack()), ""); + } + + index = filename.lastIndexOf('.'); + if(index != -1) { + filename = filename.substring(0, index); + } + + setTitle(filename); + } + } catch(Exception e) { + Log.w(TAG, "Failed to update title based off of path", e); + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getParent() { + return parent; + } + + public void setParent(String parent) { + this.parent = parent; + } + + public String getGrandParent() { + return grandParent; + } + + public void setGrandParent(String grandParent) { + this.grandParent = grandParent; + } + + public String getAlbumId() { + return albumId; + } + + public void setAlbumId(String albumId) { + this.albumId = albumId; + } + + public String getArtistId() { + return artistId; + } + + public void setArtistId(String artistId) { + this.artistId = artistId; + } + + public boolean isDirectory() { + return directory; + } + + public void setDirectory(boolean directory) { + this.directory = directory; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAlbum() { + return album; + } + + public boolean isAlbum() { + return getParent() != null || getArtist() != null; + } + + public String getAlbumDisplay() { + if(album != null && title.startsWith("Disc ")) { + return album; + } else { + return title; + } + } + + public void setAlbum(String album) { + this.album = album; + } + + public String getArtist() { + return artist; + } + + public void setArtist(String artist) { + this.artist = artist; + } + + public Integer getTrack() { + return track; + } + + public void setTrack(Integer track) { + this.track = track; + } + + public Integer getYear() { + return year; + } + + public void setYear(Integer year) { + this.year = year; + } + + public String getGenre() { + return genre; + } + + public void setGenre(String genre) { + this.genre = genre; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getSuffix() { + return suffix; + } + + public void setSuffix(String suffix) { + this.suffix = suffix; + } + + public String getTranscodedContentType() { + return transcodedContentType; + } + + public void setTranscodedContentType(String transcodedContentType) { + this.transcodedContentType = transcodedContentType; + } + + public String getTranscodedSuffix() { + return transcodedSuffix; + } + + public void setTranscodedSuffix(String transcodedSuffix) { + this.transcodedSuffix = transcodedSuffix; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } + + public Integer getDuration() { + return duration; + } + + public void setDuration(Integer duration) { + this.duration = duration; + } + + public Integer getBitRate() { + return bitRate; + } + + public void setBitRate(Integer bitRate) { + this.bitRate = bitRate; + } + + public String getCoverArt() { + return coverArt; + } + + public void setCoverArt(String coverArt) { + this.coverArt = coverArt; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public boolean isVideo() { + return video; + } + + public void setVideo(boolean video) { + this.video = video; + } + + public Integer getDiscNumber() { + return discNumber; + } + + public void setDiscNumber(Integer discNumber) { + this.discNumber = discNumber; + } + + public boolean isStarred() { + return starred; + } + + public void setStarred(boolean starred) { + this.starred = starred; + } + + public int getRating() { + return rating == null ? 0 : rating; + } + public void setRating(Integer rating) { + if(rating == null || rating == 0) { + this.rating = null; + } else { + this.rating = rating; + } + } + + public Bookmark getBookmark() { + return bookmark; + } + public void setBookmark(Bookmark bookmark) { + this.bookmark = bookmark; + } + + public int getType() { + return type; + } + public void setType(int type) { + this.type = type; + } + public boolean isSong() { + return type == TYPE_SONG; + } + public boolean isPodcast() { + return this instanceof PodcastEpisode || type == TYPE_PODCAST; + } + public boolean isAudioBook() { + return type == TYPE_AUDIO_BOOK; + } + + public int getCloseness() { + return closeness; + } + + public void setCloseness(int closeness) { + this.closeness = closeness; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Entry entry = (Entry) o; + return id.equals(entry.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return title; + } + } + + public static class EntryComparator implements Comparator { + private boolean byYear; + + public EntryComparator(boolean byYear) { + this.byYear = byYear; + } + + public int compare(Entry lhs, Entry rhs) { + if(lhs.isDirectory() && !rhs.isDirectory()) { + return -1; + } else if(!lhs.isDirectory() && rhs.isDirectory()) { + return 1; + } else if(lhs.isDirectory() && rhs.isDirectory()) { + if(byYear) { + Integer lhsYear = lhs.getYear(); + Integer rhsYear = rhs.getYear(); + if(lhsYear != null && rhsYear != null) { + return lhsYear.compareTo(rhsYear); + } else if(lhsYear != null) { + return -1; + } else if(rhsYear != null) { + return 1; + } + } + + return lhs.getTitle().compareToIgnoreCase(rhs.getTitle()); + } + + Integer lhsDisc = lhs.getDiscNumber(); + Integer rhsDisc = rhs.getDiscNumber(); + + if(lhsDisc != null && rhsDisc != null) { + if(lhsDisc < rhsDisc) { + return -1; + } else if(lhsDisc > rhsDisc) { + return 1; + } + } + + Integer lhsTrack = lhs.getTrack(); + Integer rhsTrack = rhs.getTrack(); + if(lhsTrack != null && rhsTrack != null) { + return lhsTrack.compareTo(rhsTrack); + } else if(lhsTrack != null) { + return -1; + } else if(rhsTrack != null) { + return 1; + } + + return lhs.getTitle().compareToIgnoreCase(rhs.getTitle()); + } + + public static void sort(List entries) { + sort(entries, true); + } + public static void sort(List entries, boolean byYear) { + try { + Collections.sort(entries, new EntryComparator(byYear)); + } catch (Exception e) { + Log.w(TAG, "Failed to sort MusicDirectory"); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/MusicFolder.java b/app/src/main/java/github/daneren2005/dsub/domain/MusicFolder.java new file mode 100644 index 00000000..99e86e23 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/MusicFolder.java @@ -0,0 +1,49 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; + +/** + * Represents a top level directory in which music or other media is stored. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class MusicFolder implements Serializable { + + private String id; + private String name; + + public MusicFolder() { + + } + public MusicFolder(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/PlayerQueue.java b/app/src/main/java/github/daneren2005/dsub/domain/PlayerQueue.java new file mode 100644 index 00000000..32f29725 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/PlayerQueue.java @@ -0,0 +1,30 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.domain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +public class PlayerQueue implements Serializable { + public List songs = new ArrayList(); + public List toDelete = new ArrayList(); + public int currentPlayingIndex; + public int currentPlayingPosition; + public boolean renameCurrent = false; + public Date changed = null; +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/PlayerState.java b/app/src/main/java/github/daneren2005/dsub/domain/PlayerState.java new file mode 100644 index 00000000..21f1b1a4 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/PlayerState.java @@ -0,0 +1,47 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import android.media.RemoteControlClient; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public enum PlayerState { + IDLE(RemoteControlClient.PLAYSTATE_STOPPED), + DOWNLOADING(RemoteControlClient.PLAYSTATE_BUFFERING), + PREPARING(RemoteControlClient.PLAYSTATE_BUFFERING), + PREPARED(RemoteControlClient.PLAYSTATE_STOPPED), + STARTED(RemoteControlClient.PLAYSTATE_PLAYING), + STOPPED(RemoteControlClient.PLAYSTATE_STOPPED), + PAUSED(RemoteControlClient.PLAYSTATE_PAUSED), + PAUSED_TEMP(RemoteControlClient.PLAYSTATE_PAUSED), + COMPLETED(RemoteControlClient.PLAYSTATE_STOPPED); + + private final int mRemoteControlClientPlayState; + + private PlayerState(int playState) { + mRemoteControlClientPlayState = playState; + } + + public int getRemoteControlClientPlayState() { + return mRemoteControlClientPlayState; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Playlist.java b/app/src/main/java/github/daneren2005/dsub/domain/Playlist.java new file mode 100644 index 00000000..7cd820c0 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Playlist.java @@ -0,0 +1,128 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; + +/** + * @author Sindre Mehus + */ +public class Playlist implements Serializable { + + private String id; + private String name; + private String owner; + private String comment; + private String songCount; + private String created; + private Boolean pub; + + public Playlist() { + + } + public Playlist(String id, String name) { + this.id = id; + this.name = name; + } + public Playlist(String id, String name, String owner, String comment, String songCount, String created, String pub) { + this.id = id; + this.name = name; + this.owner = (owner == null) ? "" : owner; + this.comment = (comment == null) ? "" : comment; + this.songCount = (songCount == null) ? "" : songCount; + this.created = (created == null) ? "" : created; + this.pub = (pub == null) ? null : (pub.equals("true")); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getOwner() { + return this.owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getComment() { + return this.comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public String getSongCount() { + return this.songCount; + } + + public void setSongCount(String songCount) { + this.songCount = songCount; + } + + public String getCreated() { + return this.created; + } + + public void setCreated(String created) { + this.created = created; + } + + public Boolean getPublic() { + return this.pub; + } + public void setPublic(Boolean pub) { + this.pub = pub; + } + + @Override + public String toString() { + return name; + } + + @Override + public boolean equals(Object o) { + if(o == this) { + return true; + } else if(o == null) { + return false; + } else if(o instanceof String) { + return o.equals(this.id); + } else if(o.getClass() != getClass()) { + return false; + } + + Playlist playlist = (Playlist) o; + return playlist.id.equals(this.id); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/PodcastChannel.java b/app/src/main/java/github/daneren2005/dsub/domain/PodcastChannel.java new file mode 100644 index 00000000..545f76c6 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/PodcastChannel.java @@ -0,0 +1,145 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +/** + * + * @author Scott + */ +public class PodcastChannel implements Serializable { + private String id; + private String name; + private String url; + private String description; + private String status; + private String errorMessage; + + public PodcastChannel() { + + } + + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + public void setUrl(String url) { + this.url = url; + } + + public String getDescription() { + return description; + } + public void setDescription(String description) { + this.description = description; + } + + public String getStatus() { + return status; + } + public void setStatus(String status) { + this.status = status; + } + + public String getErrorMessage() { + return errorMessage; + } + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + PodcastChannel entry = (PodcastChannel) o; + return id.equals(entry.id); + } + + public static class PodcastComparator implements Comparator { + private static String[] ignoredArticles; + + @Override + public int compare(PodcastChannel podcast1, PodcastChannel podcast2) { + String lhs = podcast1.getName(); + String rhs = podcast2.getName(); + if(lhs == null && rhs == null) { + return 0; + } else if(lhs == null) { + return 1; + } else if(rhs == null) { + return -1; + } + + lhs = lhs.toLowerCase(); + rhs = rhs.toLowerCase(); + + for(String article: ignoredArticles) { + int index = lhs.indexOf(article.toLowerCase() + " "); + if(index == 0) { + lhs = lhs.substring(article.length() + 1); + } + index = rhs.indexOf(article.toLowerCase() + " "); + if(index == 0) { + rhs = rhs.substring(article.length() + 1); + } + } + + return lhs.compareToIgnoreCase(rhs); + } + + public static List sort(List podcasts, Context context) { + SharedPreferences prefs = Util.getPreferences(context); + String ignoredArticlesString = prefs.getString(Constants.CACHE_KEY_IGNORE, "The El La Los Las Le Les"); + ignoredArticles = ignoredArticlesString.split(" "); + + Collections.sort(podcasts, new PodcastComparator()); + return podcasts; + } + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/PodcastEpisode.java b/app/src/main/java/github/daneren2005/dsub/domain/PodcastEpisode.java new file mode 100644 index 00000000..4181b3d3 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/PodcastEpisode.java @@ -0,0 +1,54 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +/** + * + * @author Scott + */ +public class PodcastEpisode extends MusicDirectory.Entry { + private String episodeId; + private String date; + private String status; + + public PodcastEpisode() { + setDirectory(false); + } + + public String getEpisodeId() { + return episodeId; + } + public void setEpisodeId(String episodeId) { + this.episodeId = episodeId; + } + + public String getDate() { + return date; + } + public void setDate(String date) { + this.date = date; + } + + public String getStatus() { + return status; + } + public void setStatus(String status) { + this.status = status; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/RemoteControlState.java b/app/src/main/java/github/daneren2005/dsub/domain/RemoteControlState.java new file mode 100644 index 00000000..47895984 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/RemoteControlState.java @@ -0,0 +1,38 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus +*/ + +package github.daneren2005.dsub.domain; + +public enum RemoteControlState { + LOCAL(0), + JUKEBOX_SERVER(1), + CHROMECAST(2), + REMOTE_CLIENT(3), + DLNA(4); + + private final int mRemoteControlState; + + private RemoteControlState(int value) { + mRemoteControlState = value; + } + + public int getValue() { + return mRemoteControlState; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/RemoteStatus.java b/app/src/main/java/github/daneren2005/dsub/domain/RemoteStatus.java new file mode 100644 index 00000000..e9749120 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/RemoteStatus.java @@ -0,0 +1,63 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class RemoteStatus { + + private Integer positionSeconds; + private Integer currentPlayingIndex; + private Float gain; + private boolean playing; + + public Integer getPositionSeconds() { + return positionSeconds; + } + + public void setPositionSeconds(Integer positionSeconds) { + this.positionSeconds = positionSeconds; + } + + public Integer getCurrentPlayingIndex() { + return currentPlayingIndex; + } + + public void setCurrentIndex(Integer currentPlayingIndex) { + this.currentPlayingIndex = currentPlayingIndex; + } + + public boolean isPlaying() { + return playing; + } + + public void setPlaying(boolean playing) { + this.playing = playing; + } + + public Float getGain() { + return gain; + } + + public void setGain(float gain) { + this.gain = gain; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/RepeatMode.java b/app/src/main/java/github/daneren2005/dsub/domain/RepeatMode.java new file mode 100644 index 00000000..7139029c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/RepeatMode.java @@ -0,0 +1,28 @@ +package github.daneren2005.dsub.domain; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public enum RepeatMode { + OFF { + @Override + public RepeatMode next() { + return ALL; + } + }, + ALL { + @Override + public RepeatMode next() { + return SINGLE; + } + }, + SINGLE { + @Override + public RepeatMode next() { + return OFF; + } + }; + + public abstract RepeatMode next(); +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/SearchCritera.java b/app/src/main/java/github/daneren2005/dsub/domain/SearchCritera.java new file mode 100644 index 00000000..20d46aa0 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/SearchCritera.java @@ -0,0 +1,55 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +/** + * The criteria for a music search. + * + * @author Sindre Mehus + */ +public class SearchCritera { + + private final String query; + private final int artistCount; + private final int albumCount; + private final int songCount; + + public SearchCritera(String query, int artistCount, int albumCount, int songCount) { + this.query = query; + this.artistCount = artistCount; + this.albumCount = albumCount; + this.songCount = songCount; + } + + public String getQuery() { + return query; + } + + public int getArtistCount() { + return artistCount; + } + + public int getAlbumCount() { + return albumCount; + } + + public int getSongCount() { + return songCount; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/domain/SearchResult.java b/app/src/main/java/github/daneren2005/dsub/domain/SearchResult.java new file mode 100644 index 00000000..3427f2ca --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/SearchResult.java @@ -0,0 +1,52 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; +import java.util.List; + +/** + * The result of a search. Contains matching artists, albums and songs. + * + * @author Sindre Mehus + */ +public class SearchResult implements Serializable { + + private final List artists; + private final List albums; + private final List songs; + + public SearchResult(List artists, List albums, List songs) { + this.artists = artists; + this.albums = albums; + this.songs = songs; + } + + public List getArtists() { + return artists; + } + + public List getAlbums() { + return albums; + } + + public List getSongs() { + return songs; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/domain/ServerInfo.java b/app/src/main/java/github/daneren2005/dsub/domain/ServerInfo.java new file mode 100644 index 00000000..3ece6af9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/ServerInfo.java @@ -0,0 +1,213 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import android.content.Context; + +import java.io.Serializable; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; + +/** + * Information about the Subsonic server. + * + * @author Sindre Mehus + */ +public class ServerInfo implements Serializable { + public static final int TYPE_SUBSONIC = 1; + public static final int TYPE_MADSONIC = 2; + private static final Map SERVERS = new ConcurrentHashMap(); + + private boolean isLicenseValid; + private Version restVersion; + private int type; + + public ServerInfo() { + type = TYPE_SUBSONIC; + } + + public boolean isLicenseValid() { + return isLicenseValid; + } + + public void setLicenseValid(boolean licenseValid) { + isLicenseValid = licenseValid; + } + + public Version getRestVersion() { + return restVersion; + } + + public void setRestVersion(Version restVersion) { + this.restVersion = restVersion; + } + + public int getRestType() { + return type; + } + public void setRestType(int type) { + this.type = type; + } + + public boolean isStockSubsonic() { + return type == TYPE_SUBSONIC; + } + public boolean isMadsonic() { + return type == TYPE_MADSONIC; + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } else if(o == null || getClass() != o.getClass()) { + return false; + } + + final ServerInfo info = (ServerInfo) o; + + if(this.type != info.type) { + return false; + } else if(this.restVersion == null || info.restVersion == null) { + // Should never be null unless just starting up + return false; + } else { + return this.restVersion.equals(info.restVersion); + } + } + + // Stub to make sure this is never used, too easy to screw up + private void saveServerInfo(Context context) { + + } + public void saveServerInfo(Context context, int instance) { + ServerInfo current = SERVERS.get(instance); + if(!this.equals(current)) { + SERVERS.put(instance, this); + FileUtil.serialize(context, this, getCacheName(context, instance)); + } + } + + public static ServerInfo getServerInfo(Context context) { + return getServerInfo(context, Util.getActiveServer(context)); + } + public static ServerInfo getServerInfo(Context context, int instance) { + ServerInfo current = SERVERS.get(instance); + if(current != null) { + return current; + } + + current = FileUtil.deserialize(context, getCacheName(context, instance), ServerInfo.class); + if(current != null) { + SERVERS.put(instance, current); + } + + return current; + } + + public static Version getServerVersion(Context context) { + return getServerVersion(context, Util.getActiveServer(context)); + } + public static Version getServerVersion(Context context, int instance) { + ServerInfo server = getServerInfo(context, instance); + if(server == null) { + return null; + } + + return server.getRestVersion(); + } + + public static boolean checkServerVersion(Context context, String requiredVersion) { + return checkServerVersion(context, requiredVersion, Util.getActiveServer(context)); + } + public static boolean checkServerVersion(Context context, String requiredVersion, int instance) { + ServerInfo server = getServerInfo(context, instance); + if(server == null) { + return false; + } + + Version version = server.getRestVersion(); + if(version == null) { + return false; + } + + Version required = new Version(requiredVersion); + return version.compareTo(required) >= 0; + } + + public static int getServerType(Context context) { + return getServerType(context, Util.getActiveServer(context)); + } + public static int getServerType(Context context, int instance) { + if(Util.isOffline(context)) { + return 0; + } + + ServerInfo server = getServerInfo(context, instance); + if(server == null) { + return 0; + } + + return server.getRestType(); + } + + public static boolean isStockSubsonic(Context context) { + return isStockSubsonic(context, Util.getActiveServer(context)); + } + public static boolean isStockSubsonic(Context context, int instance) { + return getServerType(context, instance) == TYPE_SUBSONIC; + } + + public static boolean isMadsonic(Context context) { + return isMadsonic(context, Util.getActiveServer(context)); + } + public static boolean isMadsonic(Context context, int instance) { + return getServerType(context, instance) == TYPE_MADSONIC; + } + + private static String getCacheName(Context context, int instance) { + return "server-" + Util.getRestUrl(context, null, instance, false).hashCode() + ".ser"; + } + + public static boolean hasArtistInfo(Context context) { + if(isStockSubsonic(context) && ServerInfo.checkServerVersion(context, "1.11")) { + return true; + } else if(isMadsonic(context)) { + // TODO: When madsonic adds support, figure out what REST version it is added on + return false; + } else { + return false; + } + } + + public static boolean canBookmark(Context context) { + return checkServerVersion(context, "1.9"); + } + + public static boolean canSavePlayQueue(Context context) { + return ServerInfo.checkServerVersion(context, "1.12") && !ServerInfo.isMadsonic(context); + } + + public static boolean canAlbumListPerFolder(Context context) { + return ServerInfo.checkServerVersion(context, "1.11") && !ServerInfo.isMadsonic(context); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Share.java b/app/src/main/java/github/daneren2005/dsub/domain/Share.java new file mode 100644 index 00000000..380811a7 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Share.java @@ -0,0 +1,165 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import github.daneren2005.dsub.domain.MusicDirectory.Entry; +import java.io.Serializable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class Share implements Serializable { + private String id; + private String url; + private String description; + private String username; + private Date created; + private Date lastVisited; + private Date expires; + private Long visitCount; + private List entries; + + public Share() { + entries = new ArrayList(); + } + + public String getName() { + if(description != null && !"".equals(description)) { + return description; + } else { + return url.replaceFirst(".*/([^/?]+).*", "$1"); + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Date getCreated() { + return created; + } + + public void setCreated(String created) { + if (created != null) { + try { + this.created = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).parse(created); + } catch (ParseException e) { + this.created = null; + } + } else { + this.created = null; + } + } + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastVisited() { + return lastVisited; + } + + public void setLastVisited(String lastVisited) { + if (lastVisited != null) { + try { + this.lastVisited = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).parse(lastVisited); + } catch (ParseException e) { + this.lastVisited = null; + } + } else { + this.lastVisited = null; + } + } + public void setLastVisited(Date lastVisited) { + this.lastVisited = lastVisited; + } + + public Date getExpires() { + return expires; + } + + public void setExpires(String expires) { + if (expires != null) { + try { + this.expires = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).parse(expires); + } catch (ParseException e) { + this.expires = null; + } + } else { + this.expires = null; + } + } + public void setExpires(Date expires) { + this.expires = expires; + } + + public Long getVisitCount() { + return visitCount; + } + + public void setVisitCount(Long visitCount) { + this.visitCount = visitCount; + } + + public MusicDirectory getMusicDirectory() { + MusicDirectory dir = new MusicDirectory(); + dir.addChildren(entries); + dir.setId(getId()); + dir.setName(getName()); + return dir; + } + + public List getEntries() { + return this.entries; + } + + public void addEntry(Entry entry) { + entries.add(entry); + } + } diff --git a/app/src/main/java/github/daneren2005/dsub/domain/User.java b/app/src/main/java/github/daneren2005/dsub/domain/User.java new file mode 100644 index 00000000..797a1271 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/User.java @@ -0,0 +1,117 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.domain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class User implements Serializable { + public static final String SCROBBLING = "scrobblingEnabled"; + public static final String ADMIN = "adminRole"; + public static final String SETTINGS = "settingsRole"; + public static final String DOWNLOAD = "downloadRole"; + public static final String UPLOAD = "uploadRole"; + public static final String COVERART = "coverArtRole"; + public static final String COMMENT = "commentRole"; + public static final String PODCAST = "podcastRole"; + public static final String STREAM = "streamRole"; + public static final String JUKEBOX = "jukeboxRole"; + public static final String SHARE = "shareRole"; + public static final String LASTFM = "lastFMRole"; + public static final List ROLES = new ArrayList(); + + static { + ROLES.add(ADMIN); + ROLES.add(SETTINGS); + ROLES.add(STREAM); + ROLES.add(DOWNLOAD); + ROLES.add(UPLOAD); + ROLES.add(COVERART); + ROLES.add(COMMENT); + ROLES.add(PODCAST); + ROLES.add(JUKEBOX); + ROLES.add(SHARE); + } + + private String username; + private String password; + private String email; + + private List settings = new ArrayList(); + + public User() { + + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public List getSettings() { + return settings; + } + public void setSettings(List settings) { + this.settings.clear(); + this.settings.addAll(settings); + } + public void addSetting(String name, Boolean value) { + settings.add(new Setting(name, value)); + } + + public static class Setting implements Serializable { + String name; + Boolean value; + + public Setting() { + + } + public Setting(String name, Boolean value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + public Boolean getValue() { + return value; + } + public void setValue(Boolean value) { + this.value = value; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/domain/Version.java b/app/src/main/java/github/daneren2005/dsub/domain/Version.java new file mode 100644 index 00000000..6b82ea99 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/domain/Version.java @@ -0,0 +1,181 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.domain; + +import java.io.Serializable; + +/** + * Represents the version number of the Subsonic Android app. + * + * @author Sindre Mehus + * @version $Revision: 1.3 $ $Date: 2006/01/20 21:25:16 $ + */ +public class Version implements Comparable, Serializable { + private int major; + private int minor; + private int beta; + private int bugfix; + + public Version() { + // For Kryo + } + + /** + * Creates a new version instance by parsing the given string. + * @param version A string of the format "1.27", "1.27.2" or "1.27.beta3". + */ + public Version(String version) { + String[] s = version.split("\\."); + major = Integer.valueOf(s[0]); + minor = Integer.valueOf(s[1]); + + if (s.length > 2) { + if (s[2].contains("beta")) { + beta = Integer.valueOf(s[2].replace("beta", "")); + } else { + bugfix = Integer.valueOf(s[2]); + } + } + } + + public int getMajor() { + return major; + } + + public int getMinor() { + return minor; + } + + public String getVersion() { + switch(major) { + case 1: + switch(minor) { + case 0: + return "3.8"; + case 1: + return "3.9"; + case 2: + return "4.0"; + case 3: + return "4.1"; + case 4: + return "4.2"; + case 5: + return "4.3.1"; + case 6: + return "4.5"; + case 7: + return "4.6"; + case 8: + return "4.7"; + case 9: + return "4.8"; + case 10: + return "4.9"; + case 11: + return "5.1"; + } + } + return ""; + } + + /** + * Return whether this object is equal to another. + * @param o Object to compare to. + * @return Whether this object is equals to another. + */ + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + final Version version = (Version) o; + + if (beta != version.beta) return false; + if (bugfix != version.bugfix) return false; + if (major != version.major) return false; + return minor == version.minor; + } + + /** + * Returns a hash code for this object. + * @return A hash code for this object. + */ + public int hashCode() { + int result; + result = major; + result = 29 * result + minor; + result = 29 * result + beta; + result = 29 * result + bugfix; + return result; + } + + /** + * Returns a string representation of the form "1.27", "1.27.2" or "1.27.beta3". + * @return A string representation of the form "1.27", "1.27.2" or "1.27.beta3". + */ + public String toString() { + StringBuffer buf = new StringBuffer(); + buf.append(major).append('.').append(minor); + if (beta != 0) { + buf.append(".beta").append(beta); + } else if (bugfix != 0) { + buf.append('.').append(bugfix); + } + + return buf.toString(); + } + + /** + * Compares this object with the specified object for order. + * @param version The object to compare to. + * @return A negative integer, zero, or a positive integer as this object is less than, equal to, or + * greater than the specified object. + */ + @Override + public int compareTo(Version version) { + if (major < version.major) { + return -1; + } else if (major > version.major) { + return 1; + } + + if (minor < version.minor) { + return -1; + } else if (minor > version.minor) { + return 1; + } + + if (bugfix < version.bugfix) { + return -1; + } else if (bugfix > version.bugfix) { + return 1; + } + + int thisBeta = beta == 0 ? Integer.MAX_VALUE : beta; + int otherBeta = version.beta == 0 ? Integer.MAX_VALUE : version.beta; + + if (thisBeta < otherBeta) { + return -1; + } else if (thisBeta > otherBeta) { + return 1; + } + + return 0; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/AdminFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/AdminFragment.java new file mode 100644 index 00000000..66ce5f15 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/AdminFragment.java @@ -0,0 +1,147 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.parser.SubsonicRESTException; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.UserAdapter; + +public class AdminFragment extends SelectListFragment { + private static String TAG = AdminFragment.class.getSimpleName(); + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(super.onOptionsItemSelected(item)) { + return true; + } + + switch (item.getItemId()) { + case R.id.menu_add_user: + UserUtil.addNewUser(context, this); + break; + } + + return false; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + MenuInflater inflater = context.getMenuInflater(); + if(UserUtil.isCurrentAdmin()) { + inflater.inflate(R.menu.admin_context, menu); + } else if(UserUtil.isCurrentRole(User.SETTINGS)) { + inflater.inflate(R.menu.admin_context_user, menu); + } + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + User user = objects.get(info.position); + + switch(menuItem.getItemId()) { + case R.id.admin_change_email: + UserUtil.changeEmail(context, user); + break; + case R.id.admin_change_password: + UserUtil.changePassword(context, user); + break; + case R.id.admin_delete_user: + UserUtil.deleteUser(context, user, adapter); + break; + } + + return true; + } + + @Override + public int getOptionsMenu() { + if(UserUtil.isCurrentAdmin()) { + return R.menu.admin; + } else { + return R.menu.empty; + } + } + + @Override + public ArrayAdapter getAdapter(List objs) { + return new UserAdapter(context, objs, getImageLoader()); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + try { + // Will only work if user is admin + List users = musicService.getUsers(refresh, context, listener); + if(refresh) { + UserUtil.refreshCurrentUser(context, true); + } + return users; + } catch(SubsonicRESTException e) { + // Delete cached users if not allowed to get them + String s = Util.getRestUrl(context, null, false); + String cache = "users-" + s.hashCode() + ".ser"; + File file = new File(context.getCacheDir(), cache); + file.delete(); + + List users = new ArrayList(); + User user = musicService.getUser(refresh, UserUtil.getCurrentUsername(context), context, listener); + if(user != null) { + users.add(user); + } + + UserUtil.refreshCurrentUser(context, false); + return users; + } + } + + @Override + public int getTitleResource() { + return R.string.button_bar_admin; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + User user = (User) parent.getItemAtPosition(position); + + SubsonicFragment fragment = new UserFragment(); + Bundle args = new Bundle(); + args.putSerializable(Constants.INTENT_EXTRA_NAME_ID, user); + fragment.setArguments(args); + + replaceFragment(fragment); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/ChatFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/ChatFragment.java new file mode 100644 index 00000000..3e48f1a6 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/ChatFragment.java @@ -0,0 +1,250 @@ +package github.daneren2005.dsub.fragments; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.widget.SwipeRefreshLayout; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ListView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ChatMessage; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.TabBackgroundTask; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.ChatAdapter; +import github.daneren2005.dsub.util.Constants; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * @author Joshua Bahnsen + */ +public class ChatFragment extends SubsonicFragment { + private static final String TAG = ChatFragment.class.getSimpleName(); + private ListView chatListView; + private EditText messageEditText; + private ImageButton sendButton; + private Long lastChatMessageTime = (long) 0; + private ArrayList messageList; + private ScheduledExecutorService executorService; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + if(bundle != null) { + List abstractList = (List) bundle.getSerializable(Constants.FRAGMENT_LIST); + messageList = new ArrayList(abstractList); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(Constants.FRAGMENT_LIST, (Serializable) messageList); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + rootView = inflater.inflate(R.layout.chat, container, false); + + messageEditText = (EditText) rootView.findViewById(R.id.chat_edittext); + sendButton = (ImageButton) rootView.findViewById(R.id.chat_send); + + sendButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + sendMessage(); + } + }); + + chatListView = (ListView) rootView.findViewById(R.id.chat_entries); + + messageEditText.setImeActionLabel("Send", KeyEvent.KEYCODE_ENTER); + messageEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + } + + @Override + public void afterTextChanged(Editable editable) { + sendButton.setEnabled(!Util.isNullOrWhiteSpace(editable.toString())); + } + }); + + messageEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE || (actionId == EditorInfo.IME_NULL && event.getAction() == KeyEvent.ACTION_DOWN)) { + sendMessage(); + return true; + } + + return false; + } + }); + + if(messageList == null) { + messageList = new ArrayList(); + refresh(true); + } else { + for (ChatMessage message : messageList) { + if (message.getTime() > lastChatMessageTime) { + lastChatMessageTime = message.getTime(); + } + } + + ChatAdapter chatAdapter = new ChatAdapter(context, messageList, getImageLoader()); + chatListView.setAdapter(chatAdapter); + } + setTitle(R.string.button_bar_chat); + + refreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.refresh_layout); + refreshLayout.setOnRefreshListener(this); + setupScrollList(chatListView); + + return rootView; + } + + @Override + public void onResume() { + super.onResume(); + + final Handler handler = new Handler(); + Runnable runnable = new Runnable() { + @Override + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + if(primaryFragment) { + load(false); + } else { + invalidated = true; + } + } + }); + } + }; + + SharedPreferences prefs = Util.getPreferences(context); + long refreshRate = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_CHAT_REFRESH, "30")); + if(refreshRate > 0) { + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, refreshRate * 1000L, refreshRate * 1000L, TimeUnit.MILLISECONDS); + } + } + + @Override + public void onPause() { + super.onPause(); + if(executorService != null) { + executorService.shutdown(); + executorService = null; + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + menuInflater.inflate(R.menu.abstract_top_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return super.onOptionsItemSelected(item); + + } + + @Override + protected void refresh(boolean refresh) { + load(refresh); + } + + private synchronized void load(final boolean refresh) { + BackgroundTask> task = new TabBackgroundTask>(this) { + @Override + protected List doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.getChatMessages(refresh ? 0L : lastChatMessageTime, context, this); + } + + @Override + protected void done(List result) { + if (result != null && !result.isEmpty()) { + if(refresh) { + messageList.clear(); + } + + // Reset lastChatMessageTime if we have a newer message + for (ChatMessage message : result) { + if (message.getTime() > lastChatMessageTime) { + lastChatMessageTime = message.getTime(); + } + } + + // Reverse results to show them on the bottom + Collections.reverse(result); + messageList.addAll(result); + + ChatAdapter chatAdapter = new ChatAdapter(context, messageList, getImageLoader()); + chatListView.setAdapter(chatAdapter); + } + } + }; + + task.execute(); + } + + private void sendMessage() { + final String message = messageEditText.getText().toString(); + + if (!Util.isNullOrWhiteSpace(message)) { + messageEditText.setText(""); + InputMethodManager mgr = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + mgr.hideSoftInputFromWindow(messageEditText.getWindowToken(), 0); + + BackgroundTask task = new TabBackgroundTask(this) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.addChatMessage(message, context, this); + return null; + } + + @Override + protected void done(Void result) { + load(false); + } + }; + + task.execute(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/DownloadFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/DownloadFragment.java new file mode 100644 index 00000000..59229c3f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/DownloadFragment.java @@ -0,0 +1,189 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.content.DialogInterface; +import android.os.Handler; +import android.view.ContextMenu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.DownloadFileAdapter; + +public class DownloadFragment extends SelectListFragment { + private long currentRevision; + private ScheduledExecutorService executorService; + + public DownloadFragment() { + serialize = false; + } + + @Override + public void onResume() { + super.onResume(); + + final Handler handler = new Handler(); + Runnable runnable = new Runnable() { + @Override + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + update(); + } + }); + } + }; + + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0L, 1000L, TimeUnit.MILLISECONDS); + } + + @Override + public void onPause() { + super.onPause(); + executorService.shutdown(); + } + + @Override + public int getOptionsMenu() { + return R.menu.downloading; + } + + @Override + public ArrayAdapter getAdapter(List objs) { + return new DownloadFileAdapter(context, objs); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return new ArrayList(); + } + + listView.setOnScrollListener(null); + refreshLayout.setEnabled(false); + + List songList = new ArrayList(); + songList.addAll(downloadService.getBackgroundDownloads()); + currentRevision = downloadService.getDownloadListUpdateRevision(); + return songList; + } + + @Override + public int getTitleResource() { + return R.string.button_bar_downloading; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + if(super.onOptionsItemSelected(menuItem)) { + return true; + } + + switch (menuItem.getItemId()) { + case R.id.menu_remove_all: + Util.confirmDialog(context, R.string.download_menu_remove_all, "", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().clearBackground(); + return null; + } + + @Override + protected void done(Void result) { + update(); + } + }.execute(); + } + }); + return true; + } + + return false; + } + + @Override + public void onCreateContextMenu(android.view.ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + Object selectedItem = ((DownloadFile) listView.getItemAtPosition(info.position)).getSong(); + onCreateContextMenu(menu, view, menuInfo, selectedItem); + if(selectedItem instanceof MusicDirectory.Entry && !((MusicDirectory.Entry) selectedItem).isVideo() && !Util.isOffline(context)) { + menu.removeItem(R.id.song_menu_remove_playlist); + } + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Object selectedItem = ((DownloadFile) listView.getItemAtPosition(info.position)).getSong(); + + if(onContextItemSelected(menuItem, selectedItem)) { + return true; + } + + return true; + } + + private void update() { + DownloadService downloadService = getDownloadService(); + if (downloadService == null || objects == null || adapter == null) { + return; + } + + if (currentRevision != downloadService.getDownloadListUpdateRevision()) { + List downloadFileList = downloadService.getBackgroundDownloads(); + objects.clear(); + objects.addAll(downloadFileList); + adapter.notifyDataSetChanged(); + + currentRevision = downloadService.getDownloadListUpdateRevision(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/EqualizerFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/EqualizerFragment.java new file mode 100644 index 00000000..b7080a8e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/EqualizerFragment.java @@ -0,0 +1,441 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.fragments; + +import android.content.SharedPreferences; +import android.media.audiofx.BassBoost; +import android.media.audiofx.Equalizer; +import android.os.Bundle; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import java.util.HashMap; +import java.util.Map; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.audiofx.EqualizerController; +import github.daneren2005.dsub.audiofx.LoudnessEnhancerController; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 10/27/13. + */ +public class EqualizerFragment extends SubsonicFragment { + private static final String TAG = EqualizerFragment.class.getSimpleName(); + + private static final int MENU_GROUP_PRESET = 100; + + private final Map bars = new HashMap(); + private SeekBar bassBar; + private SeekBar loudnessBar; + private EqualizerController equalizerController; + private Equalizer equalizer; + private BassBoost bass; + private LoudnessEnhancerController loudnessEnhancer; + private short masterLevel = 0; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + rootView = inflater.inflate(R.layout.equalizer, container, false); + + try { + DownloadService service = DownloadService.getInstance(); + equalizerController = service.getEqualizerController(); + equalizer = equalizerController.getEqualizer(); + bass = equalizerController.getBassBoost(); + loudnessEnhancer = equalizerController.getLoudnessEnhancerController(); + + initEqualizer(); + } catch(Exception e) { + Log.e(TAG, "Failed to initialize EQ", e); + Util.toast(context, "Failed to initialize EQ"); + context.onBackPressed(); + } + + final View presetButton = rootView.findViewById(R.id.equalizer_preset); + registerForContextMenu(presetButton); + presetButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + presetButton.showContextMenu(); + } + }); + + CheckBox enabledCheckBox = (CheckBox) rootView.findViewById(R.id.equalizer_enabled); + enabledCheckBox.setChecked(equalizer.getEnabled()); + enabledCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + try { + setEqualizerEnabled(b); + } catch(Exception e) { + Log.e(TAG, "Failed to set EQ enabled", e); + Util.toast(context, "Failed to set EQ enabled"); + context.onBackPressed(); + } + } + }); + + setTitle(R.string.equalizer_label); + + return rootView; + } + + @Override + public void onPause() { + super.onPause(); + equalizerController.saveSettings(); + + if(!equalizer.getEnabled()) { + equalizerController.release(); + } + } + + @Override + public void onResume() { + super.onResume(); + equalizerController = DownloadService.getInstance().getEqualizerController(); + equalizer = equalizerController.getEqualizer(); + bass = equalizerController.getBassBoost(); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + if(!primaryFragment) { + return; + } + + short currentPreset; + try { + currentPreset = equalizer.getCurrentPreset(); + } catch (Exception x) { + currentPreset = -1; + } + + for (short preset = 0; preset < equalizer.getNumberOfPresets(); preset++) { + MenuItem menuItem = menu.add(MENU_GROUP_PRESET, preset, preset, equalizer.getPresetName(preset)); + if (preset == currentPreset) { + menuItem.setChecked(true); + } + } + menu.setGroupCheckable(MENU_GROUP_PRESET, true, true); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + short preset = (short) menuItem.getItemId(); + for(int i = 0; i < 10; i++) { + try { + equalizer.usePreset(preset); + i = 10; + } catch (UnsupportedOperationException e) { + equalizerController.release(); + equalizer = equalizerController.getEqualizer(); + bass = equalizerController.getBassBoost(); + loudnessEnhancer = equalizerController.getLoudnessEnhancerController(); + } + } + updateBars(false); + return true; + } + + private void setEqualizerEnabled(boolean enabled) { + SharedPreferences prefs = Util.getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_EQUALIZER_ON, enabled); + editor.commit(); + for(int i = 0; i < 10; i++) { + try { + equalizer.setEnabled(enabled); + updateBars(true); + i = 10; + } catch (UnsupportedOperationException e) { + equalizerController.release(); + equalizer = equalizerController.getEqualizer(); + bass = equalizerController.getBassBoost(); + loudnessEnhancer = equalizerController.getLoudnessEnhancerController(); + } + } + } + + private void updateBars(boolean changedEnabled) { + boolean isEnabled = equalizer.getEnabled(); + short minEQLevel = equalizer.getBandLevelRange()[0]; + short maxEQLevel = equalizer.getBandLevelRange()[1]; + for (Map.Entry entry : bars.entrySet()) { + short band = entry.getKey(); + SeekBar bar = entry.getValue(); + bar.setEnabled(isEnabled); + if(band >= (short)0) { + short setLevel; + if(changedEnabled) { + setLevel = (short)(equalizer.getBandLevel(band) - masterLevel); + if(isEnabled) { + bar.setProgress(equalizer.getBandLevel(band) - minEQLevel); + } else { + bar.setProgress(-minEQLevel); + } + } else { + bar.setProgress(equalizer.getBandLevel(band) - minEQLevel); + setLevel = (short)(equalizer.getBandLevel(band) + masterLevel); + } + if(setLevel < minEQLevel) { + setLevel = minEQLevel; + } else if(setLevel > maxEQLevel) { + setLevel = maxEQLevel; + } + equalizer.setBandLevel(band, setLevel); + } else if(!isEnabled) { + bar.setProgress(-minEQLevel); + } + } + + bassBar.setEnabled(isEnabled); + if(loudnessBar != null) { + loudnessBar.setEnabled(isEnabled); + } + if(changedEnabled && !isEnabled) { + bass.setStrength((short) 0); + bassBar.setProgress(0); + if(loudnessBar != null) { + loudnessEnhancer.setGain(0); + loudnessBar.setProgress(0); + } + } + + if(!isEnabled) { + masterLevel = 0; + SharedPreferences prefs = Util.getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.PREFERENCES_EQUALIZER_SETTINGS, masterLevel); + editor.commit(); + } + } + + private void initEqualizer() { + LinearLayout layout = (LinearLayout) rootView.findViewById(R.id.equalizer_layout); + + final short minEQLevel = equalizer.getBandLevelRange()[0]; + final short maxEQLevel = equalizer.getBandLevelRange()[1]; + + // Setup Pregain + SharedPreferences prefs = Util.getPreferences(context); + masterLevel = (short)prefs.getInt(Constants.PREFERENCES_EQUALIZER_SETTINGS, 0); + initPregain(layout, minEQLevel, maxEQLevel); + + for (short i = 0; i < equalizer.getNumberOfBands(); i++) { + final short band = i; + + View bandBar = LayoutInflater.from(context).inflate(R.layout.equalizer_bar, null); + TextView freqTextView = (TextView) bandBar.findViewById(R.id.equalizer_frequency); + final TextView levelTextView = (TextView) bandBar.findViewById(R.id.equalizer_level); + SeekBar bar = (SeekBar) bandBar.findViewById(R.id.equalizer_bar); + + freqTextView.setText((equalizer.getCenterFreq(band) / 1000) + " Hz"); + + bars.put(band, bar); + bar.setMax(maxEQLevel - minEQLevel); + short level = equalizer.getBandLevel(band); + if(equalizer.getEnabled()) { + level = (short) (level - masterLevel); + } + bar.setProgress(level - minEQLevel); + bar.setEnabled(equalizer.getEnabled()); + updateLevelText(levelTextView, level); + + bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + short level = (short) (progress + minEQLevel); + if (fromUser) { + equalizer.setBandLevel(band, (short)(level + masterLevel)); + } + updateLevelText(levelTextView, level); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + layout.addView(bandBar); + } + + LinearLayout specialLayout = (LinearLayout) rootView.findViewById(R.id.special_effects_layout); + + // Setup bass booster + View bandBar = LayoutInflater.from(context).inflate(R.layout.equalizer_bar, null); + TextView freqTextView = (TextView) bandBar.findViewById(R.id.equalizer_frequency); + final TextView bassTextView = (TextView) bandBar.findViewById(R.id.equalizer_level); + bassBar = (SeekBar) bandBar.findViewById(R.id.equalizer_bar); + + freqTextView.setText(R.string.equalizer_bass_booster); + bassBar.setEnabled(equalizer.getEnabled()); + short bassLevel = 0; + if(bass.getEnabled()) { + bassLevel = bass.getRoundedStrength(); + } + bassTextView.setText(context.getResources().getString(R.string.equalizer_bass_size, bassLevel)); + bassBar.setMax(1000); + bassBar.setProgress(bassLevel); + bassBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + try { + bassTextView.setText(context.getResources().getString(R.string.equalizer_bass_size, progress)); + if (fromUser) { + if (progress > 0) { + if (!bass.getEnabled()) { + bass.setEnabled(true); + } + bass.setStrength((short) progress); + } else if (progress == 0 && bass.getEnabled()) { + bass.setStrength((short) progress); + bass.setEnabled(false); + } + } + } catch(Exception e) { + Log.w(TAG, "Error on changing bass: ", e); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + specialLayout.addView(bandBar); + + if(loudnessEnhancer != null && loudnessEnhancer.isAvailable()) { + // Setup loudness enhancer + bandBar = LayoutInflater.from(context).inflate(R.layout.equalizer_bar, null); + freqTextView = (TextView) bandBar.findViewById(R.id.equalizer_frequency); + final TextView loudnessTextView = (TextView) bandBar.findViewById(R.id.equalizer_level); + loudnessBar = (SeekBar) bandBar.findViewById(R.id.equalizer_bar); + + freqTextView.setText(R.string.equalizer_voice_booster); + loudnessBar.setEnabled(equalizer.getEnabled()); + int loudnessLevel = 0; + if(loudnessEnhancer.isEnabled()) { + loudnessLevel = (int) loudnessEnhancer.getGain(); + } + loudnessBar.setProgress(loudnessLevel / 100); + loudnessTextView.setText(context.getResources().getString(R.string.equalizer_db_size, loudnessLevel / 100)); + loudnessBar.setMax(15); + loudnessBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + try { + loudnessTextView.setText(context.getResources().getString(R.string.equalizer_db_size, progress)); + if(fromUser) { + if(progress > 0) { + if(!loudnessEnhancer.isEnabled()) { + loudnessEnhancer.enable(); + } + loudnessEnhancer.setGain(progress * 100); + } else if(progress == 0 && loudnessEnhancer.isEnabled()) { + loudnessEnhancer.setGain(progress * 100); + loudnessEnhancer.disable(); + } + } + } catch(Exception e) { + Log.w(TAG, "Error on changing loudness: ", e); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + specialLayout.addView(bandBar); + } + } + + private void initPregain(LinearLayout layout, final short minEQLevel, final short maxEQLevel) { + View bandBar = LayoutInflater.from(context).inflate(R.layout.equalizer_bar, null); + TextView freqTextView = (TextView) bandBar.findViewById(R.id.equalizer_frequency); + final TextView levelTextView = (TextView) bandBar.findViewById(R.id.equalizer_level); + SeekBar bar = (SeekBar) bandBar.findViewById(R.id.equalizer_bar); + + freqTextView.setText("Master"); + + bars.put((short)-1, bar); + bar.setMax(maxEQLevel - minEQLevel); + bar.setProgress(masterLevel - minEQLevel); + bar.setEnabled(equalizer.getEnabled()); + updateLevelText(levelTextView, masterLevel); + + bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + masterLevel = (short) (progress + minEQLevel); + if (fromUser) { + SharedPreferences prefs = Util.getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.PREFERENCES_EQUALIZER_SETTINGS, masterLevel); + editor.commit(); + for (short i = 0; i < equalizer.getNumberOfBands(); i++) { + short level = (short) ((bars.get(i).getProgress() + minEQLevel) + masterLevel); + equalizer.setBandLevel(i, level); + } + } + updateLevelText(levelTextView, masterLevel); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + layout.addView(bandBar); + } + + private void updateLevelText(TextView levelTextView, short level) { + levelTextView.setText((level > 0 ? "+" : "") + context.getResources().getString(R.string.equalizer_db_size, level / 100)); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/LyricsFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/LyricsFragment.java new file mode 100644 index 00000000..826029f5 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/LyricsFragment.java @@ -0,0 +1,107 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Lyrics; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.TabBackgroundTask; + +/** + * Displays song lyrics. + * + * @author Sindre Mehus + */ +public final class LyricsFragment extends SubsonicFragment { + private TextView artistView; + private TextView titleView; + private TextView textView; + + private Lyrics lyrics; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + if(bundle != null) { + lyrics = (Lyrics) bundle.getSerializable(Constants.FRAGMENT_LIST); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(Constants.FRAGMENT_LIST, lyrics); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + setTitle(R.string.download_menu_lyrics); + rootView = inflater.inflate(R.layout.lyrics, container, false); + artistView = (TextView) rootView.findViewById(R.id.lyrics_artist); + titleView = (TextView) rootView.findViewById(R.id.lyrics_title); + textView = (TextView) rootView.findViewById(R.id.lyrics_text); + + if(lyrics == null) { + load(); + } else { + setLyrics(); + } + + return rootView; + } + + private void load() { + BackgroundTask task = new TabBackgroundTask(this) { + @Override + protected Lyrics doInBackground() throws Throwable { + String artist = getArguments().getString(Constants.INTENT_EXTRA_NAME_ARTIST); + String title = getArguments().getString(Constants.INTENT_EXTRA_NAME_TITLE); + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.getLyrics(artist, title, context, this); + } + + @Override + protected void done(Lyrics result) { + lyrics = result; + setLyrics(); + } + }; + task.execute(); + } + + private void setLyrics() { + if (lyrics != null && lyrics.getArtist() != null) { + artistView.setText(lyrics.getArtist()); + titleView.setText(lyrics.getTitle()); + textView.setText(lyrics.getText()); + } else { + artistView.setText(R.string.lyrics_nomatch); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/MainFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/MainFragment.java new file mode 100644 index 00000000..ae38534a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/MainFragment.java @@ -0,0 +1,586 @@ +package github.daneren2005.dsub.fragments; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.StatFs; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.ListView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.Pair; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.adapter.MergeAdapter; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.view.ChangeLog; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MainFragment extends SubsonicFragment { + private static final String TAG = MainFragment.class.getSimpleName(); + private LayoutInflater inflater; + private TextView countView; + + private static final int MENU_GROUP_SERVER = 10; + private static final int MENU_ITEM_SERVER_BASE = 100; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + this.inflater = inflater; + rootView = inflater.inflate(R.layout.home, container, false); + + createLayout(); + + return rootView; + } + + @Override + public void onResume() { + super.onResume(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + menuInflater.inflate(R.menu.main, menu); + + try { + if (!ServerInfo.isMadsonic(context) || !UserUtil.isCurrentAdmin()) { + menu.setGroupVisible(R.id.madsonic, false); + } + } catch(Exception e) { + Log.w(TAG, "Error on setting madsonic invisible", e); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(super.onOptionsItemSelected(item)) { + return true; + } + + switch (item.getItemId()) { + case R.id.menu_log: + getLogs(); + return true; + case R.id.menu_about: + showAboutDialog(); + return true; + case R.id.menu_changelog: + ChangeLog changeLog = new ChangeLog(context, Util.getPreferences(context)); + changeLog.getFullLogDialog().show(); + return true; + case R.id.menu_faq: + showFAQDialog(); + return true; + case R.id.menu_rescan: + rescanServer(); + return true; + } + + return false; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + int serverCount = Util.getServerCount(context); + int activeServer = Util.getActiveServer(context); + for(int i = 1; i <= serverCount; i++) { + android.view.MenuItem menuItem = menu.add(MENU_GROUP_SERVER, MENU_ITEM_SERVER_BASE + i, MENU_ITEM_SERVER_BASE + i, Util.getServerName(context, i)); + if(i == activeServer) { + menuItem.setChecked(true); + } + } + menu.setGroupCheckable(MENU_GROUP_SERVER, true, true); + menu.setHeaderTitle(R.string.main_select_server); + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(android.view.MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + int activeServer = menuItem.getItemId() - MENU_ITEM_SERVER_BASE; + setActiveServer(activeServer); + return true; + } + + @Override + protected void refresh(boolean refresh) { + createLayout(); + } + + private void createLayout() { + View buttons = inflater.inflate(R.layout.main_buttons, null); + + final View serverButton = buttons.findViewById(R.id.main_select_server); + final TextView serverTextView = (TextView) serverButton.findViewById(R.id.main_select_server_2); + final TextView offlineButton = (TextView) buttons.findViewById(R.id.main_offline); + offlineButton.setText(Util.isOffline(context) ? R.string.main_online : R.string.main_offline); + + final View albumsTitle = buttons.findViewById(R.id.main_albums); + final View videoTitle = buttons.findViewById(R.id.main_video_section); + final View albumsNewestButton = buttons.findViewById(R.id.main_albums_newest); + countView = (TextView) buttons.findViewById(R.id.main_albums_recent_count); + final View albumsRandomButton = buttons.findViewById(R.id.main_albums_random); + final View albumsHighestButton = buttons.findViewById(R.id.main_albums_highest); + final View albumsRecentButton = buttons.findViewById(R.id.main_albums_recent); + final View albumsFrequentButton = buttons.findViewById(R.id.main_albums_frequent); + final View albumsStarredButton = buttons.findViewById(R.id.main_albums_starred); + final View albumsGenresButton = buttons.findViewById(R.id.main_albums_genres); + final View albumsYearButton = buttons.findViewById(R.id.main_albums_year); + final View albumsAlphabeticalButton = buttons.findViewById(R.id.main_albums_alphabetical); + final View videosButton = buttons.findViewById(R.id.main_videos); + + final View dummyView = rootView.findViewById(R.id.main_dummy); + + final CheckBox albumsPerFolderCheckbox = (CheckBox) buttons.findViewById(R.id.main_albums_per_folder); + if(!Util.isOffline(context) && ServerInfo.canAlbumListPerFolder(context)) { + albumsPerFolderCheckbox.setChecked(Util.getAlbumListsPerFolder(context)); + albumsPerFolderCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Util.setAlbumListsPerFolder(context, isChecked); + } + }); + } else { + albumsPerFolderCheckbox.setVisibility(View.GONE); + } + + int instance = Util.getActiveServer(context); + String name = Util.getServerName(context, instance); + serverTextView.setText(name); + + ListView list = (ListView) rootView.findViewById(R.id.main_list); + + MergeAdapter adapter = new MergeAdapter(); + if (!Util.isOffline(context)) { + adapter.addViews(Arrays.asList(serverButton), true); + } + adapter.addView(offlineButton, true); + if (!Util.isOffline(context)) { + adapter.addView(albumsTitle, false); + adapter.addViews(Arrays.asList(albumsNewestButton, albumsRandomButton), true); + if(ServerInfo.checkServerVersion(context, "1.8")) { + adapter.addView(albumsAlphabeticalButton, true); + } + if(!Util.isTagBrowsing(context)) { + adapter.addView(albumsHighestButton, true); + } + adapter.addViews(Arrays.asList(albumsStarredButton, albumsGenresButton, albumsYearButton, albumsRecentButton, albumsFrequentButton), true); + if(ServerInfo.checkServerVersion(context, "1.8") && !Util.isTagBrowsing(context)) { + adapter.addView(videoTitle, false); + adapter.addView(videosButton, true); + } + } + list.setAdapter(adapter); + registerForContextMenu(dummyView); + + list.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (view == serverButton) { + dummyView.showContextMenu(); + } else if (view == offlineButton) { + toggleOffline(); + } else if (view == albumsNewestButton) { + showAlbumList("newest"); + } else if (view == albumsRandomButton) { + showAlbumList("random"); + } else if (view == albumsHighestButton) { + showAlbumList("highest"); + } else if (view == albumsRecentButton) { + showAlbumList("recent"); + } else if (view == albumsFrequentButton) { + showAlbumList("frequent"); + } else if (view == albumsStarredButton) { + showAlbumList("starred"); + } else if(view == albumsGenresButton) { + showAlbumList("genres"); + } else if(view == albumsYearButton) { + showAlbumList("years"); + } else if(view == albumsAlphabeticalButton) { + showAlbumList("alphabeticalByName"); + } else if(view == videosButton) { + showVideos(); + } + } + }); + setTitle(R.string.common_appname); + + if(!Util.isOffline(context)) { + getMostRecentCount(); + } + } + + private void setActiveServer(int instance) { + if (Util.getActiveServer(context) != instance) { + final DownloadService service = getDownloadService(); + if (service != null) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + service.clearIncomplete(); + return null; + } + }.execute(); + + } + Util.setActiveServer(context, instance); + context.invalidate(); + UserUtil.refreshCurrentUser(context, false, true); + } + } + + private void toggleOffline() { + boolean isOffline = Util.isOffline(context); + Util.setOffline(context, !isOffline); + context.invalidate(); + DownloadService service = getDownloadService(); + if (service != null) { + service.setOnline(isOffline); + } + + // Coming back online + if(isOffline) { + int scrobblesCount = Util.offlineScrobblesCount(context); + int starsCount = Util.offlineStarsCount(context); + if(scrobblesCount > 0 || starsCount > 0){ + showOfflineSyncDialog(scrobblesCount, starsCount); + } + } + + UserUtil.seedCurrentUser(context); + } + + private void showAlbumList(String type) { + if("genres".equals(type)) { + SubsonicFragment fragment = new SelectGenreFragment(); + replaceFragment(fragment); + } else if("years".equals(type)) { + SubsonicFragment fragment = new SelectYearFragment(); + replaceFragment(fragment); + } else { + // Clear out recently added count when viewing + if("newest".equals(type)) { + SharedPreferences.Editor editor = Util.getPreferences(context).edit(); + editor.putInt(Constants.PREFERENCES_KEY_RECENT_COUNT + Util.getActiveServer(context), 0); + editor.commit(); + + // Clear immediately so doesn't still show when pressing back + setMostRecentCount(0); + } + + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 20); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0); + fragment.setArguments(args); + + replaceFragment(fragment); + } + } + private void showVideos() { + SubsonicFragment fragment = new SelectVideoFragment(); + replaceFragment(fragment); + } + + private void showOfflineSyncDialog(final int scrobbleCount, final int starsCount) { + String syncDefault = Util.getSyncDefault(context); + if(syncDefault != null) { + if("sync".equals(syncDefault)) { + syncOffline(scrobbleCount, starsCount); + return; + } else if("delete".equals(syncDefault)) { + deleteOffline(); + return; + } + } + + View checkBoxView = context.getLayoutInflater().inflate(R.layout.sync_dialog, null); + final CheckBox checkBox = (CheckBox)checkBoxView.findViewById(R.id.sync_default); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setIcon(android.R.drawable.ic_dialog_info) + .setTitle(R.string.offline_sync_dialog_title) + .setMessage(context.getResources().getString(R.string.offline_sync_dialog_message, scrobbleCount, starsCount)) + .setView(checkBoxView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + if(checkBox.isChecked()) { + Util.setSyncDefault(context, "sync"); + } + syncOffline(scrobbleCount, starsCount); + } + }).setNeutralButton(R.string.common_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + } + }).setNegativeButton(R.string.common_delete, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + if(checkBox.isChecked()) { + Util.setSyncDefault(context, "delete"); + } + deleteOffline(); + } + }); + + builder.create().show(); + } + + private void syncOffline(final int scrobbleCount, final int starsCount) { + new SilentBackgroundTask(context) { + @Override + protected Integer doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.processOfflineSyncs(context, null); + } + + @Override + protected void done(Integer result) { + if(result == scrobbleCount) { + Util.toast(context, context.getResources().getString(R.string.offline_sync_success, result)); + } else { + Util.toast(context, context.getResources().getString(R.string.offline_sync_partial, result, scrobbleCount + starsCount)); + } + } + + @Override + protected void error(Throwable error) { + Log.w(TAG, "Failed to sync offline stats", error); + String msg = context.getResources().getString(R.string.offline_sync_error) + " " + getErrorMessage(error); + Util.toast(context, msg); + } + }.execute(); + } + private void deleteOffline() { + SharedPreferences.Editor offline = Util.getOfflineSync(context).edit(); + offline.putInt(Constants.OFFLINE_SCROBBLE_COUNT, 0); + offline.putInt(Constants.OFFLINE_STAR_COUNT, 0); + offline.commit(); + } + + private void showAboutDialog() { + new LoadingTask(context) { + @Override + protected String doInBackground() throws Throwable { + File rootFolder = FileUtil.getMusicDirectory(context); + StatFs stat = new StatFs(rootFolder.getPath()); + long bytesTotalFs = (long) stat.getBlockCount() * (long) stat.getBlockSize(); + long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); + + Pair used = FileUtil.getUsedSize(context, rootFolder); + + return getResources().getString(R.string.main_about_text, + context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName, + used.getFirst(), + Util.formatLocalizedBytes(used.getSecond(), context), + Util.formatLocalizedBytes(Util.getCacheSizeMB(context) * 1024L * 1024L, context), + Util.formatLocalizedBytes(bytesAvailableFs, context), + Util.formatLocalizedBytes(bytesTotalFs, context)); + } + + @Override + protected void done(String msg) { + try { + Util.info(context, R.string.main_about_title, msg); + } catch(Exception e) { + Util.toast(context, "Failed to open dialog"); + } + } + }.execute(); + } + + private void showFAQDialog() { + Util.showHTMLDialog(context, R.string.main_faq_title, R.string.main_faq_text); + } + + private void rescanServer() { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.startRescan(context, this); + return null; + } + + @Override + protected void done(Void value) { + Util.toast(context, R.string.main_scan_complete); + } + }.execute(); + } + + private void getLogs() { + try { + final String version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName; + new LoadingTask(context) { + @Override + protected File doInBackground() throws Throwable { + updateProgress("Gathering Logs"); + File logcat = new File(FileUtil.getSubsonicDirectory(context), "logcat.txt"); + Util.delete(logcat); + Process logcatProc = null; + + try { + List progs = new ArrayList(); + progs.add("logcat"); + progs.add("-v"); + progs.add("time"); + progs.add("-d"); + progs.add("-f"); + progs.add(logcat.getCanonicalPath()); + progs.add("*:I"); + + logcatProc = Runtime.getRuntime().exec(progs.toArray(new String[progs.size()])); + logcatProc.waitFor(); + } catch(Exception e) { + Util.toast(context, "Failed to gather logs"); + } finally { + if(logcatProc != null) { + logcatProc.destroy(); + } + } + + return logcat; + } + + @Override + protected void done(File logcat) { + String footer = "Android SDK: " + Build.VERSION.SDK; + footer += "\nDevice Model: " + Build.MODEL; + footer += "\nDevice Name: " + Build.MANUFACTURER + " " + Build.PRODUCT; + footer += "\nROM: " + Build.DISPLAY; + + Intent email = new Intent(Intent.ACTION_SENDTO, + Uri.fromParts("mailto", "dsub.android@gmail.com", null)); + email.putExtra(Intent.EXTRA_SUBJECT, "DSub " + version + " Error Logs"); + email.putExtra(Intent.EXTRA_TEXT, "Describe the problem here\n\n\n" + footer); + Uri attachment = Uri.fromFile(logcat); + email.putExtra(Intent.EXTRA_STREAM, attachment); + startActivity(email); + } + }.execute(); + } catch(Exception e) {} + } + + private void getMostRecentCount() { + // Use stashed value until after refresh occurs + SharedPreferences prefs = Util.getPreferences(context); + final int startCount = prefs.getInt(Constants.PREFERENCES_KEY_RECENT_COUNT + Util.getActiveServer(context), 0); + setMostRecentCount(startCount); + + new SilentBackgroundTask(context) { + @Override + public Integer doInBackground() throws Exception { + String recentAddedFile = Util.getCacheName(context, "recent_count"); + ArrayList recents = FileUtil.deserialize(context, recentAddedFile, ArrayList.class); + if(recents == null) { + recents = new ArrayList(); + } + + MusicService musicService = MusicServiceFactory.getMusicService(context); + MusicDirectory recentlyAdded = musicService.getAlbumList("newest", 20, 0, context, null); + + // If first run, just put everything in it and return 0 + boolean firstRun = recents.isEmpty(); + + // Count how many new albums are in the list + int count = 0; + for(MusicDirectory.Entry album: recentlyAdded.getChildren()) { + if(!recents.contains(album.getId())) { + recents.add(album.getId()); + count++; + } + } + + // Keep recents list from growing infinitely + while(recents.size() > 40) { + recents.remove(0); + } + FileUtil.serialize(context, recents, recentAddedFile); + + if(firstRun) { + return 0; + } else { + // Add the old count which will get cleared out after viewing recents + count += startCount; + SharedPreferences.Editor editor = Util.getPreferences(context).edit(); + editor.putInt(Constants.PREFERENCES_KEY_RECENT_COUNT + Util.getActiveServer(context), count); + editor.commit(); + + return count; + } + } + + @Override + public void done(Integer result) { + setMostRecentCount(result); + } + + @Override + public void error(Throwable x) { + Log.w(TAG, "Failed to refresh most recent count", x); + } + }.execute(); + } + + private void setMostRecentCount(int count) { + if(count <= 0) { + countView.setVisibility(View.GONE); + } else { + String displayValue; + if(count < 10) { + displayValue = "0" + count; + } else { + displayValue = "" + count; + } + + countView.setText(displayValue); + countView.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/NowPlayingFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/NowPlayingFragment.java new file mode 100644 index 00000000..fa7e3404 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/NowPlayingFragment.java @@ -0,0 +1,1568 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ +package github.daneren2005.dsub.fragments; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.view.MenuItemCompat; +import android.support.v7.app.MediaRouteButton; +import android.util.Log; +import android.view.ContextMenu; +import android.view.Display; +import android.view.GestureDetector; +import android.view.GestureDetector.OnGestureListener; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.AnimationUtils; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.ViewFlipper; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; +import github.daneren2005.dsub.audiofx.EqualizerController; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.RepeatMode; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.adapter.DownloadFileAdapter; +import github.daneren2005.dsub.view.FadeOutAnimation; +import github.daneren2005.dsub.view.UpdateView; +import github.daneren2005.dsub.util.Util; + +import static github.daneren2005.dsub.domain.MusicDirectory.Entry; +import static github.daneren2005.dsub.domain.PlayerState.*; +import github.daneren2005.dsub.util.*; +import github.daneren2005.dsub.view.AutoRepeatButton; +import java.util.ArrayList; +import java.util.concurrent.ScheduledFuture; +import com.mobeta.android.dslv.*; +import github.daneren2005.dsub.activity.SubsonicActivity; + +public class NowPlayingFragment extends SubsonicFragment implements OnGestureListener { + private static final String TAG = NowPlayingFragment.class.getSimpleName(); + private static final int PERCENTAGE_OF_SCREEN_FOR_SWIPE = 10; + private static final int INCREMENT_TIME = 5000; + private static final int SERVICE_BACKOFF = 200; + + private static final int ACTION_PREVIOUS = 1; + private static final int ACTION_NEXT = 2; + private static final int ACTION_REWIND = 3; + private static final int ACTION_FORWARD = 4; + + private ViewFlipper playlistFlipper; + private TextView emptyTextView; + private TextView songTitleTextView; + private ImageView albumArtImageView; + private DragSortListView playlistView; + private TextView positionTextView; + private TextView durationTextView; + private TextView statusTextView; + private SeekBar progressBar; + private AutoRepeatButton previousButton; + private AutoRepeatButton nextButton; + private View pauseButton; + private View stopButton; + private View startButton; + private ImageButton repeatButton; + private View toggleListButton; + private ImageButton starButton; + private ImageButton bookmarkButton; + private ImageButton rateBadButton; + private ImageButton rateGoodButton; + private View mainLayout; + private ScheduledExecutorService executorService; + private DownloadFile currentPlaying; + private long currentRevision; + private int swipeDistance; + private int swipeVelocity; + private ScheduledFuture hideControlsFuture; + private List songList; + private DownloadFileAdapter songListAdapter; + private SilentBackgroundTask onProgressChangedTask; + private SilentBackgroundTask onCurrentChangedTask; + private SilentBackgroundTask onDownloadListChangedTask; + private boolean seekInProgress = false; + private boolean startFlipped = false; + private boolean scrollWhenLoaded = false; + private int lastY = 0; + + /** + * Called when the activity is first created. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if(savedInstanceState != null) { + if(savedInstanceState.getInt(Constants.FRAGMENT_DOWNLOAD_FLIPPER) == 1) { + startFlipped = true; + } + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(Constants.FRAGMENT_DOWNLOAD_FLIPPER, playlistFlipper.getDisplayedChild()); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + rootView = inflater.inflate(R.layout.download, container, false); + setTitle(R.string.button_bar_now_playing); + + mainLayout = rootView.findViewById(R.id.download_layout); + if(!primaryFragment) { + mainLayout.setVisibility(View.GONE); + } + + WindowManager w = context.getWindowManager(); + Display d = w.getDefaultDisplay(); + swipeDistance = (d.getWidth() + d.getHeight()) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100; + swipeVelocity = (d.getWidth() + d.getHeight()) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100; + gestureScanner = new GestureDetector(this); + + playlistFlipper = (ViewFlipper)rootView.findViewById(R.id.download_playlist_flipper); + emptyTextView = (TextView)rootView.findViewById(R.id.download_empty); + songTitleTextView = (TextView)rootView.findViewById(R.id.download_song_title); + albumArtImageView = (ImageView)rootView.findViewById(R.id.download_album_art_image); + positionTextView = (TextView)rootView.findViewById(R.id.download_position); + durationTextView = (TextView)rootView.findViewById(R.id.download_duration); + statusTextView = (TextView)rootView.findViewById(R.id.download_status); + progressBar = (SeekBar)rootView.findViewById(R.id.download_progress_bar); + playlistView = (DragSortListView)rootView.findViewById(R.id.download_list); + previousButton = (AutoRepeatButton)rootView.findViewById(R.id.download_previous); + nextButton = (AutoRepeatButton)rootView.findViewById(R.id.download_next); + pauseButton =rootView.findViewById(R.id.download_pause); + stopButton =rootView.findViewById(R.id.download_stop); + startButton =rootView.findViewById(R.id.download_start); + repeatButton = (ImageButton)rootView.findViewById(R.id.download_repeat); + bookmarkButton = (ImageButton) rootView.findViewById(R.id.download_bookmark); + rateBadButton = (ImageButton) rootView.findViewById(R.id.download_rating_bad); + rateGoodButton = (ImageButton) rootView.findViewById(R.id.download_rating_good); + toggleListButton =rootView.findViewById(R.id.download_toggle_list); + + starButton = (ImageButton)rootView.findViewById(R.id.download_star); + if(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_MENU_STAR, true)) { + starButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + DownloadFile currentDownload = getDownloadService().getCurrentPlaying(); + if (currentDownload != null) { + final Entry currentSong = currentDownload.getSong(); + toggleStarred(currentSong, new OnStarChange() { + @Override + void starChange(boolean starred) { + starButton.setImageResource(currentSong.isStarred() ? android.R.drawable.btn_star_big_on : android.R.drawable.btn_star_big_off); + } + }); + } + } + }); + } else { + starButton.setVisibility(View.GONE); + } + + View.OnTouchListener touchListener = new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent me) { + return gestureScanner.onTouchEvent(me); + } + }; + pauseButton.setOnTouchListener(touchListener); + stopButton.setOnTouchListener(touchListener); + startButton.setOnTouchListener(touchListener); + bookmarkButton.setOnTouchListener(touchListener); + rateBadButton.setOnTouchListener(touchListener); + rateGoodButton.setOnTouchListener(touchListener); + emptyTextView.setOnTouchListener(touchListener); + albumArtImageView.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent me) { + if(me.getAction() == MotionEvent.ACTION_DOWN) { + lastY = (int) me.getRawY(); + } + return gestureScanner.onTouchEvent(me); + } + }); + + previousButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + warnIfStorageUnavailable(); + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().previous(); + return null; + } + + @Override + protected void done(Void result) { + onCurrentChanged(); + onProgressChanged(); + } + }.execute(); + setControlsVisible(true); + } + }); + previousButton.setOnRepeatListener(new Runnable() { + public void run() { + changeProgress(-INCREMENT_TIME); + } + }); + + nextButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + warnIfStorageUnavailable(); + new SilentBackgroundTask(context) { + @Override + protected Boolean doInBackground() throws Throwable { + getDownloadService().next(); + return true; + } + + @Override + protected void done(Boolean result) { + if(result) { + onCurrentChanged(); + onProgressChanged(); + } + } + }.execute(); + setControlsVisible(true); + } + }); + nextButton.setOnRepeatListener(new Runnable() { + public void run() { + changeProgress(INCREMENT_TIME); + } + }); + + pauseButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().pause(); + return null; + } + + @Override + protected void done(Void result) { + onCurrentChanged(); + onProgressChanged(); + } + }.execute(); + } + }); + + stopButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().reset(); + return null; + } + + @Override + protected void done(Void result) { + onCurrentChanged(); + onProgressChanged(); + } + }.execute(); + } + }); + + startButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + warnIfStorageUnavailable(); + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + start(); + return null; + } + + @Override + protected void done(Void result) { + onCurrentChanged(); + onProgressChanged(); + } + }.execute(); + } + }); + + repeatButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + RepeatMode repeatMode = getDownloadService().getRepeatMode().next(); + getDownloadService().setRepeatMode(repeatMode); + onDownloadListChanged(); + switch (repeatMode) { + case OFF: + Util.toast(context, R.string.download_repeat_off); + break; + case ALL: + Util.toast(context, R.string.download_repeat_all); + break; + case SINGLE: + Util.toast(context, R.string.download_repeat_single); + break; + default: + break; + } + setControlsVisible(true); + } + }); + + bookmarkButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + createBookmark(); + } + }); + + rateBadButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return; + } + + DownloadFile downloadFile = downloadService.getCurrentPlaying(); + if(downloadFile == null) { + return; + } + Entry entry = downloadFile.getSong(); + + // If rating == 1, already set so unset + if(entry.getRating() == 1) { + setRating(entry, 0); + + if(context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + rateBadButton.setImageResource(R.drawable.ic_action_rating_bad_dark); + } else { + rateBadButton.setImageResource(Util.getAttribute(context, R.attr.rating_bad)); + } + } else { + // Immediately skip to the next song + downloadService.next(true); + + // Otherwise set rating to 1 + setRating(entry, 1); + rateBadButton.setImageResource(R.drawable.ic_action_rating_bad_selected); + + // Make sure good rating is blank + if (context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + rateGoodButton.setImageResource(R.drawable.ic_action_rating_good_dark); + } else { + rateGoodButton.setImageResource(Util.getAttribute(context, R.attr.rating_good)); + } + } + } + }); + rateGoodButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return; + } + + DownloadFile downloadFile = downloadService.getCurrentPlaying(); + if(downloadFile == null) { + return; + } + Entry entry = downloadFile.getSong(); + + // If rating == 5, already set so unset + if(entry.getRating() == 5) { + setRating(entry, 0); + + if (context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + rateGoodButton.setImageResource(R.drawable.ic_action_rating_good_dark); + } else { + rateGoodButton.setImageResource(Util.getAttribute(context, R.attr.rating_good)); + } + } else { + // Otherwise set rating to maximum + setRating(entry, 5); + rateGoodButton.setImageResource(R.drawable.ic_action_rating_good_selected); + + // Make sure bad rating is blank + if (context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + rateBadButton.setImageResource(R.drawable.ic_action_rating_bad_dark); + } else { + rateBadButton.setImageResource(Util.getAttribute(context, R.attr.rating_bad)); + } + } + } + }); + + toggleListButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + toggleFullscreenAlbumArt(); + setControlsVisible(true); + } + }); + + View overlay = rootView.findViewById(R.id.download_overlay_buttons); + final int overlayHeight = overlay != null ? overlay.getHeight() : -1; + albumArtImageView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if(overlayHeight == -1 || lastY < (view.getBottom() - overlayHeight)) { + toggleFullscreenAlbumArt(); + setControlsVisible(true); + } + } + }); + + progressBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().seekTo(progressBar.getProgress()); + return null; + } + + @Override + protected void done(Void result) { + seekInProgress = false; + NowPlayingFragment.this.onProgressChanged(); + } + }.execute(); + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) { + seekInProgress = true; + } + + @Override + public void onProgressChanged(final SeekBar seekBar, final int position, final boolean fromUser) { + if (fromUser) { + Util.toast(context, Util.formatDuration(position / 1000), true); + setControlsVisible(true); + } + } + }); + playlistView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, final int position, long id) { + warnIfStorageUnavailable(); + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().play(position); + return null; + } + + @Override + protected void done(Void result) { + onCurrentChanged(); + onProgressChanged(); + } + }.execute(); + } + }); + playlistView.setDropListener(new DragSortListView.DropListener() { + @Override + public void drop(final int from, final int to) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().swap(true, from, to); + onDownloadListChanged(); + + return null; + } + }.execute(); + } + }); + playlistView.setRemoveListener(new DragSortListView.RemoveListener() { + @Override + public void remove(int which) { + getDownloadService().remove(which); + onDownloadListChanged(); + } + }); + + registerForContextMenu(playlistView); + + DownloadService downloadService = getDownloadService(); + if (downloadService != null && context.getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, false)) { + context.getIntent().removeExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE); + warnIfStorageUnavailable(); + downloadService.setShufflePlayEnabled(true); + } + + if(Build.MODEL.equals("Nexus 4") || Build.MODEL.equals("GT-I9100")) { + View slider = rootView.findViewById(R.id.download_slider); + slider.setPadding(0, 0, 0, 0); + } + + return rootView; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + DownloadService downloadService = getDownloadService(); + if(Util.isOffline(context)) { + menuInflater.inflate(R.menu.nowplaying_offline, menu); + } else { + menuInflater.inflate(R.menu.nowplaying, menu); + + if(downloadService != null && downloadService.getSleepTimer()) { + menu.findItem(R.id.menu_toggle_timer).setTitle(R.string.download_stop_timer); + } + } + if(downloadService != null && downloadService.getKeepScreenOn()) { + menu.findItem(R.id.menu_screen_on_off).setChecked(true); + } + if(downloadService != null && downloadService.isRemovePlayed()) { + menu.findItem(R.id.menu_remove_played).setChecked(true); + } + + boolean equalizerAvailable = downloadService != null && downloadService.getEqualizerAvailable(); + if(equalizerAvailable && !downloadService.isRemoteEnabled()) { + SharedPreferences prefs = Util.getPreferences(context); + boolean equalizerOn = prefs.getBoolean(Constants.PREFERENCES_EQUALIZER_ON, false); + if (equalizerOn && getDownloadService() != null && getDownloadService().getEqualizerController() != null && + getDownloadService().getEqualizerController().isEnabled()) { + menu.findItem(R.id.menu_equalizer).setChecked(true); + } + } else { + menu.removeItem(R.id.menu_equalizer); + } + + if(downloadService != null) { + MenuItem mediaRouteItem = menu.findItem(R.id.menu_mediaroute); + if(mediaRouteItem != null) { + MediaRouteButton mediaRouteButton = (MediaRouteButton) MenuItemCompat.getActionView(mediaRouteItem); + mediaRouteButton.setRouteSelector(downloadService.getRemoteSelector()); + } + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + if(menuItemSelected(menuItem.getItemId(), null)) { + return true; + } + + return super.onOptionsItemSelected(menuItem); + } + + @Override + public void onCreateContextMenu(android.view.ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + if(!primaryFragment) { + return; + } + + if (view == playlistView) { + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + DownloadFile downloadFile = (DownloadFile) playlistView.getItemAtPosition(info.position); + + android.view.MenuInflater inflater = context.getMenuInflater(); + if(Util.isOffline(context)) { + inflater.inflate(R.menu.nowplaying_context_offline, menu); + } else { + inflater.inflate(R.menu.nowplaying_context, menu); + menu.findItem(R.id.menu_star).setTitle(downloadFile.getSong().isStarred() ? R.string.common_unstar : R.string.common_star); + } + + if (downloadFile.getSong().getParent() == null) { + menu.findItem(R.id.menu_show_album).setVisible(false); + menu.findItem(R.id.menu_show_artist).setVisible(false); + } + + hideMenuItems(menu, (AdapterView.AdapterContextMenuInfo) menuInfo); + } + } + + @Override + public boolean onContextItemSelected(android.view.MenuItem menuItem) { + if(!primaryFragment) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + DownloadFile downloadFile = (DownloadFile) playlistView.getItemAtPosition(info.position); + return menuItemSelected(menuItem.getItemId(), downloadFile) || super.onContextItemSelected(menuItem); + } + + private boolean menuItemSelected(int menuItemId, final DownloadFile song) { + switch (menuItemId) { + case R.id.menu_show_album: case R.id.menu_show_artist: + Entry entry = song.getSong(); + + Intent intent = new Intent(context, SubsonicFragmentActivity.class); + intent.putExtra(Constants.INTENT_EXTRA_VIEW_ALBUM, true); + String albumId; + String albumName; + if(menuItemId == R.id.menu_show_album) { + if(Util.isTagBrowsing(context)) { + albumId = entry.getAlbumId(); + } else { + albumId = entry.getParent(); + } + albumName = entry.getAlbum(); + } else { + if(Util.isTagBrowsing(context)) { + albumId = entry.getArtistId(); + } else { + albumId = entry.getGrandParent(); + if(albumId == null) { + intent.putExtra(Constants.INTENT_EXTRA_NAME_CHILD_ID, entry.getParent()); + } + } + albumName = entry.getArtist(); + intent.putExtra(Constants.INTENT_EXTRA_NAME_ARTIST, true); + } + intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, albumId); + intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, albumName); + intent.putExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE, "Artist"); + + if(Util.isOffline(context)) { + try { + // This should only be successful if this is a online song in offline mode + Integer.parseInt(entry.getParent()); + String root = FileUtil.getMusicDirectory(context).getPath(); + String id = root + "/" + entry.getPath(); + id = id.substring(0, id.lastIndexOf("/")); + if(menuItemId == R.id.menu_show_album) { + intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, id); + } + id = id.substring(0, id.lastIndexOf("/")); + if(menuItemId != R.id.menu_show_album) { + intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, id); + intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, entry.getArtist()); + intent.removeExtra(Constants.INTENT_EXTRA_NAME_CHILD_ID); + } + } catch(Exception e) { + // Do nothing, entry.getParent() is fine + } + } + + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + Util.startActivityWithoutTransition(context, intent); + return true; + case R.id.menu_lyrics: { + SubsonicFragment fragment = new LyricsFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ARTIST, song.getSong().getArtist()); + args.putString(Constants.INTENT_EXTRA_NAME_TITLE, song.getSong().getTitle()); + fragment.setArguments(args); + + replaceFragment(fragment); + return true; + } case R.id.menu_remove: + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().remove(song); + return null; + } + + @Override + protected void done(Void result) { + onDownloadListChanged(); + } + }.execute(); + return true; + case R.id.menu_delete: + List songs = new ArrayList(1); + songs.add(song.getSong()); + getDownloadService().delete(songs); + return true; + case R.id.menu_remove_all: + Util.confirmDialog(context, R.string.download_menu_remove_all, "", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().setShufflePlayEnabled(false); + getDownloadService().clear(); + return null; + } + + @Override + protected void done(Void result) { + onDownloadListChanged(); + } + }.execute(); + } + }); + return true; + case R.id.menu_screen_on_off: + if (getDownloadService().getKeepScreenOn()) { + context.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + getDownloadService().setKeepScreenOn(false); + } else { + context.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + getDownloadService().setKeepScreenOn(true); + } + context.supportInvalidateOptionsMenu(); + return true; + case R.id.menu_remove_played: + if (getDownloadService().isRemovePlayed()) { + getDownloadService().setRemovePlayed(false); + } else { + getDownloadService().setRemovePlayed(true); + } + context.supportInvalidateOptionsMenu(); + return true; + case R.id.menu_shuffle: + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().shuffle(); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, R.string.download_menu_shuffle_notification); + } + }.execute(); + return true; + case R.id.menu_save_playlist: + List entries = new LinkedList(); + for (DownloadFile downloadFile : getDownloadService().getSongs()) { + entries.add(downloadFile.getSong()); + } + createNewPlaylist(entries, true); + return true; + case R.id.menu_star: + toggleStarred(song.getSong()); + return true; + case R.id.menu_rate: + setRating(song.getSong()); + return true; + case R.id.menu_toggle_timer: + if(getDownloadService().getSleepTimer()) { + getDownloadService().stopSleepTimer(); + context.supportInvalidateOptionsMenu(); + } else { + startTimer(); + } + return true; + case R.id.menu_add_playlist: + songs = new ArrayList(1); + songs.add(song.getSong()); + addToPlaylist(songs); + return true; + case R.id.menu_info: + displaySongInfo(song.getSong()); + return true; + case R.id.menu_share: + songs = new ArrayList(1); + songs.add(song.getSong()); + createShare(songs); + return true; + case R.id.menu_equalizer: { + DownloadService downloadService = getDownloadService(); + if (downloadService != null) { + EqualizerController controller = downloadService.getEqualizerController(); + if(controller != null) { + SubsonicFragment fragment = new EqualizerFragment(); + replaceFragment(fragment); + setControlsVisible(true); + + return true; + } + } + + // Any failed condition will get here + Util.toast(context, "Failed to start equalizer. Try restarting."); + return true; + } default: + return false; + } + } + + @Override + public void onResume() { + super.onResume(); + + final Handler handler = new Handler(); + Runnable runnable = new Runnable() { + @Override + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + update(); + } + }); + } + }; + + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0L, 1000L, TimeUnit.MILLISECONDS); + + setControlsVisible(true); + + DownloadService downloadService = getDownloadService(); + if (downloadService == null || downloadService.getCurrentPlaying() == null || startFlipped) { + playlistFlipper.setDisplayedChild(1); + } + if (downloadService != null && downloadService.getKeepScreenOn()) { + context.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + context.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + updateButtons(); + + if(currentPlaying == null && downloadService != null && currentPlaying == downloadService.getCurrentPlaying()) { + getImageLoader().loadImage(albumArtImageView, (Entry) null, true, false); + } + if(downloadService != null) { + downloadService.startRemoteScan(); + } else { + // Make sure to call remote scan once the service is ready + final Runnable waitForService = new Runnable() { + @Override + public void run() { + DownloadService service = getDownloadService(); + if(service != null) { + service.startRemoteScan(); + } else { + handler.postDelayed(this, SERVICE_BACKOFF); + } + } + }; + + handler.postDelayed(waitForService, SERVICE_BACKOFF); + } + } + + @Override + public void onPause() { + super.onPause(); + executorService.shutdown(); + if(getDownloadService() != null) { + getDownloadService().stopRemoteScan(); + } + } + + @Override + public void setPrimaryFragment(boolean primary) { + super.setPrimaryFragment(primary); + if(rootView != null) { + if(primary) { + mainLayout.setVisibility(View.VISIBLE); + updateButtons(); + } else { + mainLayout.setVisibility(View.GONE); + } + } + } + + private void scheduleHideControls() { + if (hideControlsFuture != null) { + hideControlsFuture.cancel(false); + } + + final Handler handler = new Handler(); + Runnable runnable = new Runnable() { + @Override + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + setControlsVisible(false); + } + }); + } + }; + hideControlsFuture = executorService.schedule(runnable, 3000L, TimeUnit.MILLISECONDS); + } + + private void setControlsVisible(boolean visible) { + try { + long duration = 1700L; + FadeOutAnimation.createAndStart(rootView.findViewById(R.id.download_overlay_buttons), !visible, duration); + + if (visible) { + scheduleHideControls(); + } + } catch(Exception e) { + + } + } + + private void updateButtons() { + if(context == null) { + return; + } + + if(Util.isOffline(context)) { + bookmarkButton.setVisibility(View.GONE); + rateBadButton.setVisibility(View.GONE); + rateGoodButton.setVisibility(View.GONE); + } else { + if(ServerInfo.canBookmark(context)) { + bookmarkButton.setVisibility(View.VISIBLE); + } else { + bookmarkButton.setVisibility(View.GONE); + } + rateBadButton.setVisibility(View.VISIBLE); + rateGoodButton.setVisibility(View.VISIBLE); + } + } + + // Scroll to current playing/downloading. + private void scrollToCurrent() { + if (getDownloadService() == null || songListAdapter == null) { + scrollWhenLoaded = true; + return; + } + + for (int i = 0; i < songListAdapter.getCount(); i++) { + if (currentPlaying == playlistView.getItemAtPosition(i)) { + playlistView.setSelectionFromTop(i, 40); + return; + } + } + DownloadFile currentDownloading = getDownloadService().getCurrentDownloading(); + for (int i = 0; i < songListAdapter.getCount(); i++) { + if (currentDownloading == playlistView.getItemAtPosition(i)) { + playlistView.setSelectionFromTop(i, 40); + return; + } + } + } + + private void update() { + if (getDownloadService() == null) { + return; + } + + if (currentRevision != getDownloadService().getDownloadListUpdateRevision()) { + onDownloadListChanged(); + } + + if (currentPlaying != getDownloadService().getCurrentPlaying()) { + onCurrentChanged(); + } + + if(startFlipped) { + startFlipped = false; + scrollToCurrent(); + } + + onProgressChanged(); + } + + protected void startTimer() { + View dialogView = context.getLayoutInflater().inflate(R.layout.start_timer, null); + + // Setup length label + final TextView lengthBox = (TextView) dialogView.findViewById(R.id.timer_length_label); + final SharedPreferences prefs = Util.getPreferences(context); + String lengthString = prefs.getString(Constants.PREFERENCES_KEY_SLEEP_TIMER_DURATION, "5"); + int length = Integer.parseInt(lengthString); + lengthBox.setText(Util.formatDuration(length)); + + // Setup length slider + final SeekBar lengthBar = (SeekBar) dialogView.findViewById(R.id.timer_length_bar); + lengthBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + int length = getMinutes(progress); + lengthBox.setText(Util.formatDuration(length)); + seekBar.setProgress(progress); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + lengthBar.setProgress(length - 1); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.menu_set_timer) + .setView(dialogView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + int length = getMinutes(lengthBar.getProgress()); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_SLEEP_TIMER_DURATION, Integer.toString(length)); + editor.commit(); + + getDownloadService().setSleepTimerDuration(length); + getDownloadService().startSleepTimer(); + context.supportInvalidateOptionsMenu(); + } + }) + .setNegativeButton(R.string.common_cancel, null); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private int getMinutes(int progress) { + if(progress < 30) { + return progress + 1; + } else if(progress < 61) { + return (progress - 30) * 5 + getMinutes(29); + } else { + return (progress - 61) * 15 + getMinutes(60); + } + } + + private void toggleFullscreenAlbumArt() { + if (playlistFlipper.getDisplayedChild() == 1) { + playlistFlipper.setInAnimation(AnimationUtils.loadAnimation(context, R.anim.push_down_in)); + playlistFlipper.setOutAnimation(AnimationUtils.loadAnimation(context, R.anim.push_down_out)); + playlistFlipper.setDisplayedChild(0); + } else { + scrollToCurrent(); + playlistFlipper.setInAnimation(AnimationUtils.loadAnimation(context, R.anim.push_up_in)); + playlistFlipper.setOutAnimation(AnimationUtils.loadAnimation(context, R.anim.push_up_out)); + playlistFlipper.setDisplayedChild(1); + + UpdateView.triggerUpdate(); + } + } + + private void start() { + DownloadService service = getDownloadService(); + PlayerState state = service.getPlayerState(); + if (state == PAUSED || state == COMPLETED || state == STOPPED) { + service.start(); + } else if (state == STOPPED || state == IDLE) { + warnIfStorageUnavailable(); + int current = service.getCurrentPlayingIndex(); + // TODO: Use play() method. + if (current == -1) { + service.play(0); + } else { + service.play(current); + } + } + } + private void onDownloadListChanged() { + onDownloadListChanged(false); + } + private void onDownloadListChanged(final boolean refresh) { + final DownloadService downloadService = getDownloadService(); + if (downloadService == null || onDownloadListChangedTask != null) { + return; + } + + onDownloadListChangedTask = new SilentBackgroundTask(context) { + int currentPlayingIndex; + int size; + + @Override + protected Void doInBackground() throws Throwable { + currentPlayingIndex = downloadService.getCurrentPlayingIndex() + 1; + size = downloadService.size(); + + return null; + } + + @Override + protected void done(Void result) { + List list; + list = downloadService.getSongs(); + + if(downloadService.isShufflePlayEnabled()) { + emptyTextView.setText(R.string.download_shuffle_loading); + } + else { + emptyTextView.setText(R.string.download_empty); + } + + if(songListAdapter == null || refresh) { + songList = new ArrayList(); + songList.addAll(list); + playlistView.setAdapter(songListAdapter = new DownloadFileAdapter(context, songList)); + } else { + songList.clear(); + songList.addAll(list); + songListAdapter.notifyDataSetChanged(); + } + + emptyTextView.setVisibility(list.isEmpty() ? View.VISIBLE : View.GONE); + currentRevision = downloadService.getDownloadListUpdateRevision(); + + switch (downloadService.getRepeatMode()) { + case OFF: + if("light".equals(SubsonicActivity.getThemeName()) | "light_fullscreen".equals(SubsonicActivity.getThemeName())) { + repeatButton.setImageResource(R.drawable.media_repeat_off_light); + } else { + repeatButton.setImageResource(R.drawable.media_repeat_off); + } + break; + case ALL: + repeatButton.setImageResource(R.drawable.media_repeat_all); + break; + case SINGLE: + repeatButton.setImageResource(R.drawable.media_repeat_single); + break; + default: + break; + } + + if(scrollWhenLoaded) { + scrollToCurrent(); + scrollWhenLoaded = false; + } + + setSubtitle(context.getResources().getString(R.string.download_playing_out_of, currentPlayingIndex, size)); + onDownloadListChangedTask = null; + if(onCurrentChangedTask != null) { + onCurrentChangedTask.execute(); + } else if(onProgressChangedTask != null) { + onProgressChangedTask.execute(); + } + } + }; + onDownloadListChangedTask.execute(); + } + + private void onCurrentChanged() { + final DownloadService downloadService = getDownloadService(); + if (downloadService == null || onCurrentChangedTask != null) { + return; + } + + onCurrentChangedTask = new SilentBackgroundTask(context) { + int currentPlayingIndex; + int currentPlayingSize; + + @Override + protected Void doInBackground() throws Throwable { + currentPlaying = downloadService.getCurrentPlaying(); + currentPlayingIndex = downloadService.getCurrentPlayingIndex() + 1; + currentPlayingSize = downloadService.size(); + return null; + } + + @Override + protected void done(Void result) { + if (currentPlaying != null) { + Entry song = currentPlaying.getSong(); + songTitleTextView.setText(song.getTitle()); + getImageLoader().loadImage(albumArtImageView, song, true, true); + starButton.setImageResource(song.isStarred() ? android.R.drawable.btn_star_big_on : android.R.drawable.btn_star_big_off); + setSubtitle(context.getResources().getString(R.string.download_playing_out_of, currentPlayingIndex, currentPlayingSize)); + + int badRating, goodRating, bookmark; + if(song.getRating() == 1) { + badRating = R.drawable.ic_action_rating_bad_selected; + } else if(context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + badRating = R.drawable.ic_action_rating_bad_dark; + } else { + badRating = Util.getAttribute(context, R.attr.rating_bad); + } + rateBadButton.setImageResource(badRating); + + if(song.getRating() == 5) { + goodRating = R.drawable.ic_action_rating_good_selected; + } else if(context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + goodRating = R.drawable.ic_action_rating_good_dark; + } else { + goodRating = Util.getAttribute(context, R.attr.rating_good); + } + rateGoodButton.setImageResource(goodRating); + + if(song.getBookmark() != null) { + bookmark = R.drawable.ic_menu_bookmark_selected; + } else if(context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + bookmark = R.drawable.ic_menu_bookmark_dark; + } else { + bookmark = Util.getAttribute(context, R.attr.bookmark); + } + bookmarkButton.setImageResource(bookmark); + } else { + songTitleTextView.setText(null); + getImageLoader().loadImage(albumArtImageView, (Entry) null, true, false); + starButton.setImageResource(android.R.drawable.btn_star_big_off); + setSubtitle(null); + } + onCurrentChangedTask = null; + if(onProgressChangedTask != null) { + onProgressChangedTask.execute(); + } + } + }; + + if(onDownloadListChangedTask == null) { + onCurrentChangedTask.execute(); + } + } + + private void onProgressChanged() { + // Make sure to only be trying to run one of these at a time + if (getDownloadService() == null || onProgressChangedTask != null) { + return; + } + + onProgressChangedTask = new SilentBackgroundTask(context) { + DownloadService downloadService; + int millisPlayed; + Integer duration; + PlayerState playerState; + boolean isSeekable; + + @Override + protected Void doInBackground() throws Throwable { + downloadService = getDownloadService(); + millisPlayed = Math.max(0, downloadService.getPlayerPosition()); + duration = downloadService.getPlayerDuration(); + playerState = getDownloadService().getPlayerState(); + isSeekable = downloadService.isSeekable(); + return null; + } + + @Override + protected void done(Void result) { + if (currentPlaying != null) { + int millisTotal = duration == null ? 0 : duration; + + positionTextView.setText(Util.formatDuration(millisPlayed / 1000)); + if(millisTotal > 0) { + durationTextView.setText(Util.formatDuration(millisTotal / 1000)); + } else { + durationTextView.setText("-:--"); + } + progressBar.setMax(millisTotal == 0 ? 100 : millisTotal); // Work-around for apparent bug. + if(!seekInProgress) { + progressBar.setProgress(millisPlayed); + } + progressBar.setEnabled(isSeekable); + } else { + positionTextView.setText("0:00"); + durationTextView.setText("-:--"); + progressBar.setProgress(0); + progressBar.setEnabled(false); + } + + switch (playerState) { + case DOWNLOADING: + if(currentPlaying != null) { + long bytes = currentPlaying.getPartialFile().length(); + statusTextView.setText(context.getResources().getString(R.string.download_playerstate_downloading, Util.formatLocalizedBytes(bytes, context))); + } + break; + case PREPARING: + statusTextView.setText(R.string.download_playerstate_buffering); + break; + default: + if(currentPlaying != null) { + String artist = ""; + if(currentPlaying.getSong().getArtist() != null) { + artist = currentPlaying.getSong().getArtist() + " - "; + } + statusTextView.setText(artist + currentPlaying.getSong().getAlbum()); + } else { + statusTextView.setText(null); + } + break; + } + + switch (playerState) { + case STARTED: + pauseButton.setVisibility(View.VISIBLE); + stopButton.setVisibility(View.INVISIBLE); + startButton.setVisibility(View.INVISIBLE); + break; + case DOWNLOADING: + case PREPARING: + pauseButton.setVisibility(View.INVISIBLE); + stopButton.setVisibility(View.VISIBLE); + startButton.setVisibility(View.INVISIBLE); + break; + default: + pauseButton.setVisibility(View.INVISIBLE); + stopButton.setVisibility(View.INVISIBLE); + startButton.setVisibility(View.VISIBLE); + break; + } + + onProgressChangedTask = null; + } + }; + if(onDownloadListChangedTask == null && onCurrentChangedTask == null) { + onProgressChangedTask.execute(); + } + } + + private void changeProgress(final int ms) { + final DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return; + } + + new SilentBackgroundTask(context) { + boolean isJukeboxEnabled; + int msPlayed; + Integer duration; + PlayerState playerState; + int seekTo; + + @Override + protected Void doInBackground() throws Throwable { + msPlayed = Math.max(0, downloadService.getPlayerPosition()); + duration = downloadService.getPlayerDuration(); + playerState = getDownloadService().getPlayerState(); + int msTotal = duration == null ? 0 : duration; + if(msPlayed + ms > msTotal) { + seekTo = msTotal; + } else { + seekTo = msPlayed + ms; + } + downloadService.seekTo(seekTo); + return null; + } + + @Override + protected void done(Void result) { + progressBar.setProgress(seekTo); + } + }.execute(); + } + + private void createBookmark() { + DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return; + } + + final DownloadFile currentDownload = downloadService.getCurrentPlaying(); + if(currentDownload == null) { + return; + } + + View dialogView = context.getLayoutInflater().inflate(R.layout.create_bookmark, null); + final EditText commentBox = (EditText)dialogView.findViewById(R.id.comment_text); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.download_save_bookmark_title) + .setView(dialogView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + String comment = commentBox.getText().toString(); + + createBookmark(currentDownload, comment); + } + }) + .setNegativeButton(R.string.common_cancel, null); + AlertDialog dialog = builder.create(); + dialog.show(); + } + private void createBookmark(final DownloadFile currentDownload, final String comment) { + DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return; + } + + final Entry currentSong = currentDownload.getSong(); + final int position = downloadService.getPlayerPosition(); + final Bookmark oldBookmark = currentSong.getBookmark(); + currentSong.setBookmark(new Bookmark(position)); + bookmarkButton.setImageResource(R.drawable.ic_menu_bookmark_selected); + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.createBookmark(currentSong, position, comment, context, null); + + new EntryInstanceUpdater(currentSong) { + @Override + public void update(Entry found) { + found.setBookmark(new Bookmark(position)); + } + }.execute(); + + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, R.string.download_save_bookmark); + setControlsVisible(true); + } + + @Override + protected void error(Throwable error) { + Log.w(TAG, "Failed to create bookmark", error); + currentSong.setBookmark(oldBookmark); + + // If no bookmark at start, then return to no bookmark + if(oldBookmark == null) { + int bookmark; + if(context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { + bookmark = R.drawable.ic_menu_bookmark_dark; + } else { + bookmark = Util.getAttribute(context, R.attr.bookmark); + } + bookmarkButton.setImageResource(bookmark); + } + + String msg; + if(error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.download_save_bookmark_failed) + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + @Override + public boolean onDown(MotionEvent me) { + setControlsVisible(true); + return false; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + final DownloadService downloadService = getDownloadService(); + if (downloadService == null || e1 == null || e2 == null) { + return false; + } + + // Right to Left swipe + int action = 0; + if (e1.getX() - e2.getX() > swipeDistance && Math.abs(velocityX) > swipeVelocity) { + action = ACTION_NEXT; + } + // Left to Right swipe + else if (e2.getX() - e1.getX() > swipeDistance && Math.abs(velocityX) > swipeVelocity) { + action = ACTION_PREVIOUS; + } + // Top to Bottom swipe + else if (e2.getY() - e1.getY() > swipeDistance && Math.abs(velocityY) > swipeVelocity) { + action = ACTION_FORWARD; + } + // Bottom to Top swipe + else if (e1.getY() - e2.getY() > swipeDistance && Math.abs(velocityY) > swipeVelocity) { + action = ACTION_REWIND; + } + + if(action > 0) { + final int performAction = action; + warnIfStorageUnavailable(); + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + switch(performAction) { + case ACTION_NEXT: + downloadService.next(); + break; + case ACTION_PREVIOUS: + downloadService.previous(); + break; + case ACTION_FORWARD: + downloadService.seekTo(downloadService.getPlayerPosition() + DownloadService.FAST_FORWARD); + break; + case ACTION_REWIND: + downloadService.seekTo(downloadService.getPlayerPosition() - DownloadService.REWIND); + break; + } + + onProgressChanged(); + if(performAction == ACTION_NEXT || performAction == ACTION_PREVIOUS) { + onCurrentChanged(); + } + return null; + } + }.execute(); + + return true; + } else { + return false; + } + } + + @Override + public void onLongPress(MotionEvent e) { + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return false; + } + + @Override + public void onShowPress(MotionEvent e) { + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return false; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/PreferenceCompatFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/PreferenceCompatFragment.java new file mode 100644 index 00000000..9f413b3b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/PreferenceCompatFragment.java @@ -0,0 +1,313 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.util.Constants; + +public class PreferenceCompatFragment extends SubsonicFragment { + private static final int FIRST_REQUEST_CODE = 100; + private static final int MSG_BIND_PREFERENCES = 1; + private static final String PREFERENCES_TAG = "android:preferences"; + private boolean mHavePrefs; + private boolean mInitDone; + private ListView mList; + private PreferenceManager mPreferenceManager; + + private Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + + case MSG_BIND_PREFERENCES: + bindPreferences(); + break; + } + } + }; + + final private Runnable mRequestFocus = new Runnable() { + public void run() { + mList.focusableViewAvailable(mList); + } + }; + + private void bindPreferences() { + PreferenceScreen localPreferenceScreen = getPreferenceScreen(); + if (localPreferenceScreen != null) { + ListView localListView = getListView(); + localPreferenceScreen.bind(localListView); + } + } + + private void ensureList() { + if (mList == null) { + View view = getView(); + if (view == null) { + throw new IllegalStateException("Content view not yet created"); + } + + View listView = view.findViewById(android.R.id.list); + if (!(listView instanceof ListView)) { + throw new RuntimeException("Content has view with id attribute 'android.R.id.list' that is not a ListView class"); + } + + mList = (ListView)listView; + if (mList == null) { + throw new RuntimeException("Your content must have a ListView whose id attribute is 'android.R.id.list'"); + } + + mHandler.post(mRequestFocus); + } + } + + private void postBindPreferences() { + if (mHandler.hasMessages(MSG_BIND_PREFERENCES)) { + mHandler.obtainMessage(MSG_BIND_PREFERENCES).sendToTarget(); + } + } + + private void requirePreferenceManager() { + if (this.mPreferenceManager == null) { + throw new RuntimeException("This should be called after super.onCreate."); + } + } + + public void addPreferencesFromIntent(Intent intent) { + requirePreferenceManager(); + PreferenceScreen screen = inflateFromIntent(intent, getPreferenceScreen()); + setPreferenceScreen(screen); + } + + public void addPreferencesFromResource(int resId) { + requirePreferenceManager(); + PreferenceScreen screen = inflateFromResource(getActivity(), resId, getPreferenceScreen()); + setPreferenceScreen(screen); + } + + public Preference findPreference(CharSequence key) { + if (mPreferenceManager == null) { + return null; + } + return mPreferenceManager.findPreference(key); + } + + public ListView getListView() { + ensureList(); + return mList; + } + + public PreferenceManager getPreferenceManager() { + return mPreferenceManager; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + getListView().setScrollBarStyle(0); + if (mHavePrefs) { + bindPreferences(); + } + mInitDone = true; + if (savedInstanceState != null) { + Bundle localBundle = savedInstanceState.getBundle(PREFERENCES_TAG); + if (localBundle != null) { + PreferenceScreen screen = getPreferenceScreen(); + if (screen != null) { + screen.restoreHierarchyState(localBundle); + } + } + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + dispatchActivityResult(requestCode, resultCode, data); + } + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + mPreferenceManager = createPreferenceManager(); + + int res = this.getArguments().getInt(Constants.INTENT_EXTRA_FRAGMENT_TYPE, 0); + if(res != 0) { + addPreferencesFromResource(res); + } + } + + @Override + public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle) { + return paramLayoutInflater.inflate(R.layout.preferences, paramViewGroup, false); + } + + @Override + public void onDestroy() { + super.onDestroy(); + dispatchActivityDestroy(); + } + + @Override + public void onDestroyView() { + mList = null; + mHandler.removeCallbacks(mRequestFocus); + mHandler.removeMessages(MSG_BIND_PREFERENCES); + super.onDestroyView(); + } + + @Override + public void onSaveInstanceState(Bundle bundle) { + super.onSaveInstanceState(bundle); + PreferenceScreen screen = getPreferenceScreen(); + if (screen != null) { + Bundle localBundle = new Bundle(); + screen.saveHierarchyState(localBundle); + bundle.putBundle(PREFERENCES_TAG, localBundle); + } + } + + @Override + public void onStop() { + super.onStop(); + dispatchActivityStop(); + } + + /** Access methods with visibility private **/ + + private PreferenceManager createPreferenceManager() { + try { + Constructor c = PreferenceManager.class.getDeclaredConstructor(Activity.class, int.class); + c.setAccessible(true); + return c.newInstance(this.getActivity(), FIRST_REQUEST_CODE); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private PreferenceScreen getPreferenceScreen() { + try { + Method m = PreferenceManager.class.getDeclaredMethod("getPreferenceScreen"); + m.setAccessible(true); + return (PreferenceScreen) m.invoke(mPreferenceManager); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setPreferenceScreen(PreferenceScreen preferenceScreen) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("setPreferences", PreferenceScreen.class); + m.setAccessible(true); + boolean result = (Boolean) m.invoke(mPreferenceManager, preferenceScreen); + if (result && preferenceScreen != null) { + mHavePrefs = true; + if (mInitDone) { + postBindPreferences(); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void dispatchActivityResult(int requestCode, int resultCode, Intent data) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityResult", int.class, int.class, Intent.class); + m.setAccessible(true); + m.invoke(mPreferenceManager, requestCode, resultCode, data); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void dispatchActivityDestroy() { + try { + Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityDestroy"); + m.setAccessible(true); + m.invoke(mPreferenceManager); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void dispatchActivityStop() { + try { + Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityStop"); + m.setAccessible(true); + m.invoke(mPreferenceManager); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + + private void setFragment(PreferenceFragment preferenceFragment) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("setFragment", PreferenceFragment.class); + m.setAccessible(true); + m.invoke(mPreferenceManager, preferenceFragment); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public PreferenceScreen inflateFromResource(Context context, int resId, PreferenceScreen rootPreferences) { + PreferenceScreen preferenceScreen ; + try { + Method m = PreferenceManager.class.getDeclaredMethod("inflateFromResource", Context.class, int.class, PreferenceScreen.class); + m.setAccessible(true); + preferenceScreen = (PreferenceScreen) m.invoke(mPreferenceManager, context, resId, rootPreferences); + } catch (Exception e) { + throw new RuntimeException(e); + } + return preferenceScreen; + } + + public PreferenceScreen inflateFromIntent(Intent queryIntent, PreferenceScreen rootPreferences) { + PreferenceScreen preferenceScreen ; + try { + Method m = PreferenceManager.class.getDeclaredMethod("inflateFromIntent", Intent.class, PreferenceScreen.class); + m.setAccessible(true); + preferenceScreen = (PreferenceScreen) m.invoke(mPreferenceManager, queryIntent, rootPreferences); + } catch (Exception e) { + throw new RuntimeException(e); + } + return preferenceScreen; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SearchFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SearchFragment.java new file mode 100644 index 00000000..0f1598dd --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SearchFragment.java @@ -0,0 +1,368 @@ +package github.daneren2005.dsub.fragments; + +import java.util.ArrayList; +import java.util.List; +import java.util.Arrays; + +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.widget.SwipeRefreshLayout; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.MenuItem; +import android.widget.AdapterView; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.net.Uri; +import android.view.ViewGroup; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.SearchCritera; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.adapter.ArtistAdapter; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.adapter.EntryAdapter; +import github.daneren2005.dsub.adapter.MergeAdapter; +import github.daneren2005.dsub.util.TabBackgroundTask; +import github.daneren2005.dsub.util.Util; + +public class SearchFragment extends SubsonicFragment { + private static final String TAG = SearchFragment.class.getSimpleName(); + + private static final int DEFAULT_ARTISTS = 3; + private static final int DEFAULT_ALBUMS = 5; + private static final int DEFAULT_SONGS = 10; + + private static final int MAX_ARTISTS = 10; + private static final int MAX_ALBUMS = 20; + private static final int MAX_SONGS = 25; + private ListView list; + + private View artistsHeading; + private View albumsHeading; + private View songsHeading; + private View moreArtistsButton; + private View moreAlbumsButton; + private View moreSongsButton; + private SearchResult searchResult; + private MergeAdapter mergeAdapter; + private ArtistAdapter artistAdapter; + private ListAdapter moreArtistsAdapter; + private EntryAdapter albumAdapter; + private ListAdapter moreAlbumsAdapter; + private ListAdapter moreSongsAdapter; + private EntryAdapter songAdapter; + private boolean skipSearch = false; + private String currentQuery; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if(savedInstanceState != null) { + searchResult = (SearchResult) savedInstanceState.getSerializable(Constants.FRAGMENT_LIST); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(Constants.FRAGMENT_LIST, searchResult); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + rootView = inflater.inflate(R.layout.abstract_list_fragment, container, false); + setTitle(R.string.search_title); + + View buttons = inflater.inflate(R.layout.search_buttons, null); + + artistsHeading = buttons.findViewById(R.id.search_artists); + albumsHeading = buttons.findViewById(R.id.search_albums); + songsHeading = buttons.findViewById(R.id.search_songs); + + moreArtistsButton = buttons.findViewById(R.id.search_more_artists); + moreAlbumsButton = buttons.findViewById(R.id.search_more_albums); + moreSongsButton = buttons.findViewById(R.id.search_more_songs); + + refreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.refresh_layout); + refreshLayout.setEnabled(false); + + list = (ListView) rootView.findViewById(R.id.fragment_list); + + list.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (view == moreArtistsButton) { + expandArtists(); + } else if (view == moreAlbumsButton) { + expandAlbums(); + } else if (view == moreSongsButton) { + expandSongs(); + } else { + Object item = parent.getItemAtPosition(position); + if (item instanceof Artist) { + onArtistSelected((Artist) item, false); + } else if (item instanceof MusicDirectory.Entry) { + MusicDirectory.Entry entry = (MusicDirectory.Entry) item; + if (entry.isDirectory()) { + onAlbumSelected(entry, false); + } else if (entry.isVideo()) { + onVideoSelected(entry); + } else { + onSongSelected(entry, false, true, true, false); + } + + } + } + } + }); + registerForContextMenu(list); + context.onNewIntent(context.getIntent()); + + if(searchResult != null) { + skipSearch = true; + populateList(); + } + + return rootView; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + menuInflater.inflate(R.menu.search, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_search: + context.startSearch(currentQuery, false, null, false); + return true; + } + + return super.onOptionsItemSelected(item); + + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + Object selectedItem = list.getItemAtPosition(info.position); + onCreateContextMenu(menu, view, menuInfo, selectedItem); + if(selectedItem instanceof MusicDirectory.Entry && !((MusicDirectory.Entry) selectedItem).isVideo() && !Util.isOffline(context)) { + menu.removeItem(R.id.song_menu_remove_playlist); + } + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Object selectedItem = list.getItemAtPosition(info.position); + + if(onContextItemSelected(menuItem, selectedItem)) { + return true; + } + + return true; + } + + @Override + public void setPrimaryFragment(boolean primary) { + super.setPrimaryFragment(primary); + } + + @Override + public void refresh(boolean refresh) { + context.onNewIntent(context.getIntent()); + } + + public void search(final String query, final boolean autoplay) { + if(skipSearch) { + skipSearch = false; + return; + } + currentQuery = query; + + mergeAdapter = new MergeAdapter(); + list.setAdapter(mergeAdapter); + + BackgroundTask task = new TabBackgroundTask(this) { + @Override + protected SearchResult doInBackground() throws Throwable { + SearchCritera criteria = new SearchCritera(query, MAX_ARTISTS, MAX_ALBUMS, MAX_SONGS); + MusicService service = MusicServiceFactory.getMusicService(context); + return service.search(criteria, context, this); + } + + @Override + protected void done(SearchResult result) { + searchResult = result; + populateList(); + if (autoplay) { + autoplay(query); + } + + } + }; + task.execute(); + } + + public void populateList() { + mergeAdapter = new MergeAdapter(); + + if (searchResult != null) { + List artists = searchResult.getArtists(); + if (!artists.isEmpty()) { + mergeAdapter.addView(artistsHeading); + List displayedArtists = new ArrayList(artists.subList(0, Math.min(DEFAULT_ARTISTS, artists.size()))); + artistAdapter = new ArtistAdapter(context, displayedArtists); + mergeAdapter.addAdapter(artistAdapter); + if (artists.size() > DEFAULT_ARTISTS) { + moreArtistsAdapter = mergeAdapter.addView(moreArtistsButton, true); + } + } + + List albums = searchResult.getAlbums(); + if (!albums.isEmpty()) { + mergeAdapter.addView(albumsHeading); + List displayedAlbums = new ArrayList(albums.subList(0, Math.min(DEFAULT_ALBUMS, albums.size()))); + albumAdapter = new EntryAdapter(context, getImageLoader(), displayedAlbums, false); + mergeAdapter.addAdapter(albumAdapter); + if (albums.size() > DEFAULT_ALBUMS) { + moreAlbumsAdapter = mergeAdapter.addView(moreAlbumsButton, true); + } + } + + List songs = searchResult.getSongs(); + if (!songs.isEmpty()) { + mergeAdapter.addView(songsHeading); + List displayedSongs = new ArrayList(songs.subList(0, Math.min(DEFAULT_SONGS, songs.size()))); + songAdapter = new EntryAdapter(context, getImageLoader(), displayedSongs, false); + mergeAdapter.addAdapter(songAdapter); + if (songs.size() > DEFAULT_SONGS) { + moreSongsAdapter = mergeAdapter.addView(moreSongsButton, true); + } + } + + boolean empty = searchResult.getArtists().isEmpty() && searchResult.getAlbums().isEmpty() && searchResult.getSongs().isEmpty(); + if(empty) { + setEmpty(true); + } + } + + list.setAdapter(mergeAdapter); + } + + private void expandArtists() { + artistAdapter.clear(); + for (Artist artist : searchResult.getArtists()) { + artistAdapter.add(artist); + } + artistAdapter.notifyDataSetChanged(); + mergeAdapter.removeAdapter(moreArtistsAdapter); + mergeAdapter.notifyDataSetChanged(); + } + + private void expandAlbums() { + albumAdapter.clear(); + for (MusicDirectory.Entry album : searchResult.getAlbums()) { + albumAdapter.add(album); + } + albumAdapter.notifyDataSetChanged(); + mergeAdapter.removeAdapter(moreAlbumsAdapter); + mergeAdapter.notifyDataSetChanged(); + } + + private void expandSongs() { + songAdapter.clear(); + for (MusicDirectory.Entry song : searchResult.getSongs()) { + songAdapter.add(song); + } + songAdapter.notifyDataSetChanged(); + mergeAdapter.removeAdapter(moreSongsAdapter); + mergeAdapter.notifyDataSetChanged(); + } + + private void onArtistSelected(Artist artist, boolean autoplay) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, artist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, artist.getName()); + if(autoplay) { + args.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true); + } + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + fragment.setArguments(args); + + replaceFragment(fragment); + } + + private void onAlbumSelected(MusicDirectory.Entry album, boolean autoplay) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, album.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, album.getTitle()); + if(autoplay) { + args.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true); + } + fragment.setArguments(args); + + replaceFragment(fragment); + } + + private void onSongSelected(MusicDirectory.Entry song, boolean save, boolean append, boolean autoplay, boolean playNext) { + DownloadService downloadService = getDownloadService(); + if (downloadService != null) { + if (!append) { + downloadService.clear(); + } + downloadService.download(Arrays.asList(song), save, false, playNext, false); + if (autoplay) { + downloadService.play(downloadService.size() - 1); + } + + Util.toast(context, getResources().getQuantityString(R.plurals.select_album_n_songs_added, 1, 1)); + } + } + + private void onVideoSelected(MusicDirectory.Entry entry) { + int maxBitrate = Util.getMaxVideoBitrate(context); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(MusicServiceFactory.getMusicService(context).getVideoUrl(maxBitrate, context, entry.getId()))); + startActivity(intent); + } + + private void autoplay(String query) { + Artist artist = searchResult.getArtists().isEmpty() ? null : searchResult.getArtists().get(0); + MusicDirectory.Entry album = searchResult.getAlbums().isEmpty() ? null : searchResult.getAlbums().get(0); + MusicDirectory.Entry song = searchResult.getSongs().isEmpty() ? null : searchResult.getSongs().get(0); + + if(artist != null && query.equals(artist.getName())) { + onArtistSelected(artist, true); + } else if(album != null && query.equals(album.getTitle())) { + onAlbumSelected(album, true); + } else if(song != null) { + onSongSelected(song, false, false, true, false); + } else if(album != null) { + onAlbumSelected(album, true); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectArtistFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectArtistFragment.java new file mode 100644 index 00000000..5488c95b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectArtistFragment.java @@ -0,0 +1,333 @@ +package github.daneren2005.dsub.fragments; + +import android.annotation.TargetApi; +import android.os.Build; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.ArtistAdapter; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class SelectArtistFragment extends SelectListFragment { + private static final String TAG = SelectArtistFragment.class.getSimpleName(); + private static final int MENU_GROUP_MUSIC_FOLDER = 10; + + private View folderButtonParent; + private View folderButton; + private TextView folderName; + private List musicFolders = null; + private List entries; + private String groupId; + private String groupName; + + public SelectArtistFragment() { + super(); + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + if(bundle != null) { + musicFolders = (List) bundle.getSerializable(Constants.FRAGMENT_LIST2); + } + artist = true; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(Constants.FRAGMENT_LIST2, (Serializable) musicFolders); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + Bundle args = getArguments(); + if(args != null) { + groupId = args.getString(Constants.INTENT_EXTRA_NAME_ID); + groupName = args.getString(Constants.INTENT_EXTRA_NAME_NAME); + + if(groupName != null) { + setTitle(groupName); + context.invalidateOptionsMenu(); + } + } + + folderButton = null; + super.onCreateView(inflater, container, bundle); + + if("4.4.2".equals(Build.VERSION.RELEASE)) { + listView.setFastScrollAlwaysVisible(true); + } + + if(objects != null && currentTask == null) { + if (Util.isOffline(context) || Util.isTagBrowsing(context) || groupId != null) { + folderButton.setVisibility(View.GONE); + } + setMusicFolders(); + } + + return rootView; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + Object entry = listView.getItemAtPosition(info.position); + + if (entry instanceof Artist) { + onCreateContextMenu(menu, view, menuInfo, entry); + } else if (info.position == 0) { + String musicFolderId = Util.getSelectedMusicFolderId(context); + MenuItem menuItem = menu.add(MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders); + if (musicFolderId == null) { + menuItem.setChecked(true); + } + if (musicFolders != null) { + for (int i = 0; i < musicFolders.size(); i++) { + MusicFolder musicFolder = musicFolders.get(i); + menuItem = menu.add(MENU_GROUP_MUSIC_FOLDER, i, i + 1, musicFolder.getName()); + if (musicFolder.getId().equals(musicFolderId)) { + menuItem.setChecked(true); + } + } + } + menu.setGroupCheckable(MENU_GROUP_MUSIC_FOLDER, true, true); + } + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Artist artist = (Artist) listView.getItemAtPosition(info.position); + + if (artist != null) { + return onContextItemSelected(menuItem, artist); + } else if (info.position == 0) { + MusicFolder selectedFolder = menuItem.getItemId() == -1 ? null : musicFolders.get(menuItem.getItemId()); + String musicFolderId = selectedFolder == null ? null : selectedFolder.getId(); + String musicFolderName = selectedFolder == null ? context.getString(R.string.select_artist_all_folders) + : selectedFolder.getName(); + Util.setSelectedMusicFolderId(context, musicFolderId); + folderName.setText(musicFolderName); + context.invalidate(); + } + + return true; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (view == folderButtonParent) { + selectFolder(); + } else { + Artist artist = (Artist) parent.getItemAtPosition(position); + + SubsonicFragment fragment; + if((Util.isFirstLevelArtist(context) || Util.isOffline(context) || Util.isTagBrowsing(context)) || "root".equals(artist.getId()) || groupId != null) { + fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, artist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, artist.getName()); + if ("root".equals(artist.getId())) { + args.putSerializable(Constants.FRAGMENT_LIST, (Serializable) entries); + } + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + fragment.setArguments(args); + } else { + fragment = new SelectArtistFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, artist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, artist.getName()); + fragment.setArguments(args); + } + + replaceFragment(fragment); + } + } + + @Override + public void onFinishRefresh() { + setMusicFolders(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + super.onCreateOptionsMenu(menu, menuInflater); + + if(Util.isOffline(context) || Util.isTagBrowsing(context) || groupId != null) { + menu.removeItem(R.id.menu_first_level_artist); + } else { + if (Util.isFirstLevelArtist(context)) { + menu.findItem(R.id.menu_first_level_artist).setChecked(true); + } + } + } + + @Override + public int getOptionsMenu() { + return R.menu.select_artist; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(super.onOptionsItemSelected(item)) { + return true; + } + + switch (item.getItemId()) { + case R.id.menu_first_level_artist: + toggleFirstLevelArtist(); + break; + } + + return false; + } + + @Override + public ArrayAdapter getAdapter(List objects) { + createMusicFolderButton(); + return new ArtistAdapter(context, objects); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + List artists; + if(groupId == null) { + if (!Util.isOffline(context) && !Util.isTagBrowsing(context)) { + musicFolders = musicService.getMusicFolders(refresh, context, listener); + + // Hide folders option if there is only one + if (musicFolders.size() == 1) { + musicFolders = null; + Util.setSelectedMusicFolderId(context, null); + } + } + String musicFolderId = Util.getSelectedMusicFolderId(context); + + Indexes indexes = musicService.getIndexes(musicFolderId, refresh, context, listener); + artists = new ArrayList(indexes.getShortcuts().size() + indexes.getArtists().size()); + artists.addAll(indexes.getShortcuts()); + artists.addAll(indexes.getArtists()); + entries = indexes.getEntries(); + } else { + artists = new ArrayList(); + MusicDirectory dir = musicService.getMusicDirectory(groupId, groupName, refresh, context, listener); + for(MusicDirectory.Entry entry: dir.getChildren(true, false)) { + Artist artist = new Artist(); + artist.setId(entry.getId()); + artist.setName(entry.getTitle()); + artist.setStarred(entry.isStarred()); + artists.add(artist); + } + + entries = new ArrayList(); + entries.addAll(dir.getChildren(false, true)); + if(!entries.isEmpty()) { + Artist root = new Artist(); + root.setId("root"); + root.setName("Root"); + root.setIndex("#"); + artists.add(root); + } + } + + return artists; + } + + @Override + public int getTitleResource() { + return groupId == null ? R.string.button_bar_browse : 0; + } + + private void createMusicFolderButton() { + if(folderButton == null) { + folderButtonParent = context.getLayoutInflater().inflate(R.layout.select_artist_header, listView, false); + folderName = (TextView) folderButtonParent.findViewById(R.id.select_artist_folder_2); + listView.addHeaderView(folderButtonParent); + folderButton = folderButtonParent.findViewById(R.id.select_artist_folder); + } + + if (Util.isOffline(context) || Util.isTagBrowsing(context) || musicFolders == null) { + folderButton.setVisibility(View.GONE); + } else { + folderButton.setVisibility(View.VISIBLE); + } + } + + @Override + public void setEmpty(boolean empty) { + super.setEmpty(empty); + + if(empty && !Util.isOffline(context)) { + createMusicFolderButton(); + setMusicFolders(); + + objects.clear(); + listView.setAdapter(new ArtistAdapter(context, objects)); + listView.setVisibility(View.VISIBLE); + + View view = rootView.findViewById(R.id.tab_progress); + LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) view.getLayoutParams(); + params.height = 0; + params.weight = 5; + view.setLayoutParams(params); + } + } + + private void setMusicFolders() { + // Display selected music folder + if (musicFolders != null) { + String musicFolderId = Util.getSelectedMusicFolderId(context); + if (musicFolderId == null) { + folderName.setText(R.string.select_artist_all_folders); + } else { + for (MusicFolder musicFolder : musicFolders) { + if (musicFolder.getId().equals(musicFolderId)) { + folderName.setText(musicFolder.getName()); + break; + } + } + } + } + } + + private void selectFolder() { + folderButton.showContextMenu(); + } + + private void toggleFirstLevelArtist() { + Util.toggleFirstLevelArtist(context); + context.invalidateOptionsMenu(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectBookmarkFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectBookmarkFragment.java new file mode 100644 index 00000000..c71d99f6 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectBookmarkFragment.java @@ -0,0 +1,131 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus +*/ +package github.daneren2005.dsub.fragments; + +import android.view.ContextMenu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.DownloadActivity; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.BookmarkAdapter; + +import java.util.Arrays; +import java.util.List; + +public class SelectBookmarkFragment extends SelectListFragment { + private static final String TAG = SelectBookmarkFragment.class.getSimpleName(); + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + MenuInflater inflater = context.getMenuInflater(); + inflater.inflate(R.menu.select_bookmark_context, menu); + + hideMenuItems(menu, (AdapterView.AdapterContextMenuInfo) menuInfo); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + MusicDirectory.Entry bookmark = objects.get(info.position); + + switch(menuItem.getItemId()) { + case R.id.bookmark_menu_info: + displayBookmarkInfo(bookmark); + return true; + case R.id.bookmark_menu_delete: + deleteBookmark(bookmark, adapter); + return true; + } + + if(onContextItemSelected(menuItem, bookmark)) { + return true; + } + + return true; + } + + @Override + public int getOptionsMenu() { + return R.menu.abstract_top_menu; + } + + @Override + public ArrayAdapter getAdapter(List bookmarks) { + return new BookmarkAdapter(context, bookmarks); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + return musicService.getBookmarks(refresh, context, listener).getChildren(); + } + + @Override + public int getTitleResource() { + return R.string.button_bar_bookmarks; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return; + } + + final MusicDirectory.Entry bookmark = (MusicDirectory.Entry) parent.getItemAtPosition(position); + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + downloadService.clear(); + downloadService.download(Arrays.asList(bookmark), false, true, false, false, 0, bookmark.getBookmark().getPosition()); + return null; + } + + @Override + protected void done(Void result) { + Util.startActivityWithoutTransition(context, DownloadActivity.class); + } + }.execute(); + } + + private void displayBookmarkInfo(final MusicDirectory.Entry entry) { + Bookmark bookmark = entry.getBookmark(); + String comment = bookmark.getComment(); + if(comment == null) { + comment = ""; + } + + String msg = context.getResources().getString(R.string.bookmark_details, + entry.getTitle(), Util.formatDuration(bookmark.getPosition() / 1000), + Util.formatDate(bookmark.getCreated()), Util.formatDate(bookmark.getChanged()), comment); + + Util.info(context, R.string.bookmark_details_title, msg, false); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectDirectoryFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectDirectoryFragment.java new file mode 100644 index 00000000..841a6369 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectDirectoryFragment.java @@ -0,0 +1,1597 @@ +package github.daneren2005.dsub.fragments; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.v4.widget.SwipeRefreshLayout; +import android.text.Html; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.util.Log; +import android.view.ContextMenu; +import android.view.Display; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.GridView; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.RatingBar; +import android.widget.RelativeLayout; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.adapter.AlbumGridAdapter; +import github.daneren2005.dsub.adapter.EntryAdapter; + +import java.io.Serializable; +import java.util.Iterator; +import java.util.List; + +import github.daneren2005.dsub.activity.DownloadActivity; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.Pair; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.TabBackgroundTask; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.AlbumListAdapter; +import github.daneren2005.dsub.view.HeaderGridView; +import github.daneren2005.dsub.view.MyLeadingMarginSpan2; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static github.daneren2005.dsub.domain.MusicDirectory.Entry; + +public class SelectDirectoryFragment extends SubsonicFragment implements AdapterView.OnItemClickListener { + private static final String TAG = SelectDirectoryFragment.class.getSimpleName(); + + private GridView albumList; + private ListView entryList; + private Boolean licenseValid; + private EntryAdapter entryAdapter; + private List albums; + private List entries; + private boolean albumContext = false; + private boolean addAlbumHeader = false; + private LoadTask currentTask; + private ArtistInfo artistInfo; + private String artistInfoDelayed; + + String id; + String name; + Entry directory; + String playlistId; + String playlistName; + boolean playlistOwner; + String podcastId; + String podcastName; + String podcastDescription; + String albumListType; + String albumListExtra; + int albumListSize; + boolean refreshListing = false; + boolean showAll = false; + boolean restoredInstance = false; + boolean lookupParent = false; + boolean largeAlbums = false; + boolean topTracks = false; + String lookupEntry; + + public SelectDirectoryFragment() { + super(); + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + if(bundle != null) { + entries = (List) bundle.getSerializable(Constants.FRAGMENT_LIST); + albums = (List) bundle.getSerializable(Constants.FRAGMENT_LIST2); + artistInfo = (ArtistInfo) bundle.getSerializable(Constants.FRAGMENT_EXTRA); + restoredInstance = true; + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(Constants.FRAGMENT_LIST, (Serializable) entries); + outState.putSerializable(Constants.FRAGMENT_LIST2, (Serializable) albums); + outState.putSerializable(Constants.FRAGMENT_EXTRA, (Serializable) artistInfo); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + Bundle args = getArguments(); + if(args != null) { + id = args.getString(Constants.INTENT_EXTRA_NAME_ID); + name = args.getString(Constants.INTENT_EXTRA_NAME_NAME); + directory = (Entry) args.getSerializable(Constants.INTENT_EXTRA_NAME_DIRECTORY); + playlistId = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID); + playlistName = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME); + playlistOwner = args.getBoolean(Constants.INTENT_EXTRA_NAME_PLAYLIST_OWNER, false); + podcastId = args.getString(Constants.INTENT_EXTRA_NAME_PODCAST_ID); + podcastName = args.getString(Constants.INTENT_EXTRA_NAME_PODCAST_NAME); + podcastDescription = args.getString(Constants.INTENT_EXTRA_NAME_PODCAST_DESCRIPTION); + Object shareObj = args.getSerializable(Constants.INTENT_EXTRA_NAME_SHARE); + share = (shareObj != null) ? (Share) shareObj : null; + albumListType = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE); + albumListExtra = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_EXTRA); + albumListSize = args.getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0); + refreshListing = args.getBoolean(Constants.INTENT_EXTRA_REFRESH_LISTINGS); + artist = args.getBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, false); + lookupEntry = args.getString(Constants.INTENT_EXTRA_SEARCH_SONG); + topTracks = args.getBoolean(Constants.INTENT_EXTRA_TOP_TRACKS); + showAll = args.getBoolean(Constants.INTENT_EXTRA_SHOW_ALL); + + String childId = args.getString(Constants.INTENT_EXTRA_NAME_CHILD_ID); + if(childId != null) { + id = childId; + lookupParent = true; + } + if(entries == null) { + entries = (List) args.getSerializable(Constants.FRAGMENT_LIST); + albums = (List) args.getSerializable(Constants.FRAGMENT_LIST2); + + if(albums == null) { + albums = new ArrayList(); + } + } + } + + rootView = inflater.inflate(R.layout.select_album, container, false); + + refreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.refresh_layout); + refreshLayout.setOnRefreshListener(this); + + entryList = (ListView) rootView.findViewById(R.id.select_album_entries); + entryList.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + entryList.setOnItemClickListener(this); + setupScrollList(entryList); + + if(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_LARGE_ALBUM_ART, true)) { + largeAlbums = true; + } + + if(albumListType == null || "starred".equals(albumListType) || !largeAlbums) { + albumList = (GridView) inflater.inflate(R.layout.unscrollable_grid_view, entryList, false); + addAlbumHeader = true; + } else { + ViewGroup rootGroup = (ViewGroup) rootView.findViewById(R.id.select_album_layout); + albumList = (GridView) inflater.inflate(R.layout.grid_view, rootGroup, false); + rootGroup.removeView(entryList); + rootGroup.addView(albumList); + + setupScrollList(albumList); + } + registerForContextMenu(entryList); + setupAlbumList(); + + if(entries == null) { + if(primaryFragment || secondaryFragment) { + load(false); + } else { + invalidated = true; + } + } else { + licenseValid = true; + finishLoading(); + } + + if(name != null) { + setTitle(name); + } + + return rootView; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + if(licenseValid == null) { + menuInflater.inflate(R.menu.empty, menu); + } else if(albumListType != null && !"starred".equals(albumListType)) { + menuInflater.inflate(R.menu.select_album_list, menu); + } else if(artist && !showAll) { + menuInflater.inflate(R.menu.select_album, menu); + + if(!ServerInfo.isMadsonic(context)) { + menu.removeItem(R.id.menu_top_tracks); + } + if(!ServerInfo.checkServerVersion(context, "1.11") || (id != null && "root".equals(id))) { + menu.removeItem(R.id.menu_radio); + menu.removeItem(R.id.menu_similar_artists); + } else if(ServerInfo.isMadsonic(context)) { + menu.removeItem(R.id.menu_similar_artists); + } + } else { + if(podcastId == null) { + if(Util.isOffline(context)) { + menuInflater.inflate(R.menu.select_song_offline, menu); + } + else { + menuInflater.inflate(R.menu.select_song, menu); + + if(playlistId == null || !playlistOwner) { + menu.removeItem(R.id.menu_remove_playlist); + } + } + + SharedPreferences prefs = Util.getPreferences(context); + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_PLAY_NEXT, true)) { + menu.setGroupVisible(R.id.hide_play_next, false); + } + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_PLAY_LAST, true)) { + menu.setGroupVisible(R.id.hide_play_last, false); + } + } else { + if(Util.isOffline(context)) { + menuInflater.inflate(R.menu.select_podcast_episode_offline, menu); + } + else { + menuInflater.inflate(R.menu.select_podcast_episode, menu); + + if(!UserUtil.canPodcast()) { + menu.removeItem(R.id.menu_download_all); + } + } + } + } + + if("starred".equals(albumListType)) { + menuInflater.inflate(R.menu.unstar, menu); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_play_now: + playNow(false, false); + return true; + case R.id.menu_play_last: + playNow(false, true); + return true; + case R.id.menu_play_next: + playNow(false, true, true); + return true; + case R.id.menu_shuffle: + playNow(true, false); + return true; + case R.id.menu_download: + downloadBackground(false); + selectAll(false, false); + return true; + case R.id.menu_cache: + downloadBackground(true); + selectAll(false, false); + return true; + case R.id.menu_delete: + delete(); + selectAll(false, false); + return true; + case R.id.menu_add_playlist: + if(getSelectedSongs().isEmpty()) { + selectAll(true, false); + } + addToPlaylist(getSelectedSongs()); + return true; + case R.id.menu_remove_playlist: + removeFromPlaylist(playlistId, playlistName, getSelectedIndexes()); + return true; + case R.id.menu_download_all: + downloadAllPodcastEpisodes(); + return true; + case R.id.menu_show_all: + setShowAll(); + return true; + case R.id.menu_unstar: + unstarSelected(); + return true; + case R.id.menu_top_tracks: + showTopTracks(); + return true; + case R.id.menu_similar_artists: + showSimilarArtists(id); + return true; + case R.id.menu_radio: + startArtistRadio(id); + return true; + } + + return super.onOptionsItemSelected(item); + + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + + Entry entry; + if(view.getId() == R.id.select_album_entries) { + if(info.position == 0) { + return; + } + entry = (Entry) entryList.getItemAtPosition(info.position); + // When List has Grid embedded in header, this is called against the header as well + if(entry != null) { + albumContext = false; + } + } else { + entry = (Entry) albumList.getItemAtPosition(info.position); + albumContext = true; + } + + // Don't try to display a context menu if error here + if(entry == null) { + return; + } + + onCreateContextMenu(menu, view, menuInfo, entry); + if(!entry.isVideo() && !Util.isOffline(context) && (playlistId == null || !playlistOwner) && (podcastId == null || Util.isOffline(context) && podcastId != null)) { + menu.removeItem(R.id.song_menu_remove_playlist); + } + // Remove show artists if parent is not set and if not on a album list + if((albumListType == null || (entry.getParent() == null && entry.getArtistId() == null)) && !Util.isOffline(context)) { + menu.removeItem(R.id.album_menu_show_artist); + } + if(podcastId != null && !Util.isOffline(context)) { + if(UserUtil.canPodcast()) { + String status = ((PodcastEpisode)entry).getStatus(); + if("completed".equals(status)) { + menu.removeItem(R.id.song_menu_server_download); + } + } else { + menu.removeItem(R.id.song_menu_server_download); + menu.removeItem(R.id.song_menu_server_delete); + } + } + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Object selectedItem; + int headers = entryList.getHeaderViewsCount(); + if(albumContext) { + selectedItem = albumList.getItemAtPosition(info.position); + } else { + if(info.position == 0) { + return false; + } + selectedItem = entries.get(info.position - headers); + } + + if(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_PLAY_NOW_AFTER, false) && menuItem.getItemId() == R.id.song_menu_play_now) { + List songs = new ArrayList(); + Iterator it = entries.listIterator(info.position - headers); + while(it.hasNext()) { + songs.add((Entry) it.next()); + } + + playNow(songs); + return true; + } + + if(onContextItemSelected(menuItem, selectedItem)) { + return true; + } + + switch (menuItem.getItemId()) { + case R.id.song_menu_remove_playlist: + removeFromPlaylist(playlistId, playlistName, Arrays.asList(info.position - headers)); + break; + case R.id.song_menu_server_download: + downloadPodcastEpisode((PodcastEpisode)selectedItem); + break; + case R.id.song_menu_server_delete: + deletePodcastEpisode((PodcastEpisode)selectedItem); + break; + } + + return true; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (position >= 0) { + Entry entry = (Entry) parent.getItemAtPosition(position); + if (entry.isDirectory()) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.getTitle()); + args.putSerializable(Constants.INTENT_EXTRA_NAME_DIRECTORY, entry); + if ("newest".equals(albumListType)) { + args.putBoolean(Constants.INTENT_EXTRA_REFRESH_LISTINGS, true); + } + if(entry.getArtist() == null && entry.getParent() == null) { + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + } + fragment.setArguments(args); + + replaceFragment(fragment, true); + } else if (entry.isVideo()) { + playVideo(entry); + } else if(entry instanceof PodcastEpisode) { + String status = ((PodcastEpisode)entry).getStatus(); + if("error".equals(status)) { + Util.toast(context, R.string.select_podcasts_error); + return; + } else if(!"completed".equals(status)) { + Util.toast(context, R.string.select_podcasts_skipped); + return; + } + + playNow(Arrays.asList(entry)); + } + } + } + + @Override + protected void refresh(boolean refresh) { + if(!"root".equals(id)) { + load(refresh); + } + } + + private void load(boolean refresh) { + if(refreshListing) { + refresh = true; + } + + if(currentTask != null) { + currentTask.cancel(); + } + + entryList.setVisibility(View.INVISIBLE); + if (playlistId != null) { + getPlaylist(playlistId, playlistName, refresh); + } else if(podcastId != null) { + getPodcast(podcastId, podcastName, refresh); + } else if (share != null) { + if(showAll) { + getRecursiveMusicDirectory(share.getId(), share.getName(), refresh); + } else { + getShare(share, refresh); + } + } else if (albumListType != null) { + getAlbumList(albumListType, albumListSize); + } else { + if(showAll) { + getRecursiveMusicDirectory(id, name, refresh); + } else if(topTracks) { + getTopTracks(id, name, refresh); + } else { + getMusicDirectory(id, name, refresh); + } + } + } + + private void getMusicDirectory(final String id, final String name, final boolean refresh) { + setTitle(name); + + new LoadTask(refresh) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + MusicDirectory dir = getMusicDirectory(id, name, refresh, service, this); + + if(lookupParent && dir.getParent() != null) { + dir = getMusicDirectory(dir.getParent(), name, refresh, service, this); + + // Update the fragment pointers so other stuff works correctly + SelectDirectoryFragment.this.id = dir.getId(); + SelectDirectoryFragment.this.name = dir.getName(); + } else if(id != null && directory == null && dir.getParent() != null && !artist) { + // View Album, try to lookup parent to get a complete entry to use for starring + MusicDirectory parentDir = getMusicDirectory(dir.getParent(), name, refresh, true, service, this); + for(Entry child: parentDir.getChildren()) { + if(id.equals(child.getId())) { + directory = child; + break; + } + } + } + + return dir; + } + + @Override + protected void done(Pair result) { + SelectDirectoryFragment.this.name = result.getFirst().getName(); + setTitle(SelectDirectoryFragment.this.name); + super.done(result); + } + }.execute(); + } + + private void getRecursiveMusicDirectory(final String id, final String name, final boolean refresh) { + setTitle(name); + + new LoadTask(refresh) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + MusicDirectory root; + if(share == null) { + root = getMusicDirectory(id, name, refresh, service, this); + } else { + root = share.getMusicDirectory(); + } + List songs = new ArrayList(); + getSongsRecursively(root, songs); + root.replaceChildren(songs); + return root; + } + + private void getSongsRecursively(MusicDirectory parent, List songs) throws Exception { + songs.addAll(parent.getChildren(false, true)); + for (Entry dir : parent.getChildren(true, false)) { + MusicService musicService = MusicServiceFactory.getMusicService(context); + + MusicDirectory musicDirectory; + if(Util.isTagBrowsing(context) && !Util.isOffline(context)) { + musicDirectory = musicService.getAlbum(dir.getId(), dir.getTitle(), false, context, this); + } else { + musicDirectory = musicService.getMusicDirectory(dir.getId(), dir.getTitle(), false, context, this); + } + getSongsRecursively(musicDirectory, songs); + } + } + + @Override + protected void done(Pair result) { + SelectDirectoryFragment.this.name = result.getFirst().getName(); + setTitle(SelectDirectoryFragment.this.name); + super.done(result); + } + }.execute(); + } + + private void getPlaylist(final String playlistId, final String playlistName, final boolean refresh) { + setTitle(playlistName); + + new LoadTask(refresh) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + return service.getPlaylist(refresh, playlistId, playlistName, context, this); + } + }.execute(); + } + + private void getPodcast(final String podcastId, final String podcastName, final boolean refresh) { + setTitle(podcastName); + + new LoadTask(refresh) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + return service.getPodcastEpisodes(refresh, podcastId, context, this); + } + }.execute(); + } + + private void getShare(final Share share, final boolean refresh) { + setTitle(share.getName()); + + new LoadTask(refresh) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + return share.getMusicDirectory(); + } + }.execute(); + } + + private void getTopTracks(final String id, final String name, final boolean refresh) { + setTitle(name); + + new LoadTask(refresh) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + return service.getTopTrackSongs(name, 20, context, this); + } + }.execute(); + } + + private void getAlbumList(final String albumListType, final int size) { + if ("newest".equals(albumListType)) { + setTitle(R.string.main_albums_newest); + } else if ("random".equals(albumListType)) { + setTitle(R.string.main_albums_random); + } else if ("highest".equals(albumListType)) { + setTitle(R.string.main_albums_highest); + } else if ("recent".equals(albumListType)) { + setTitle(R.string.main_albums_recent); + } else if ("frequent".equals(albumListType)) { + setTitle(R.string.main_albums_frequent); + } else if ("starred".equals(albumListType)) { + setTitle(R.string.main_albums_starred); + } else if("genres".equals(albumListType) || "years".equals(albumListType)) { + setTitle(albumListExtra); + } else if("alphabeticalByName".equals(albumListType)) { + setTitle(R.string.main_albums_alphabetical); + } + + new LoadTask(true) { + @Override + protected MusicDirectory load(MusicService service) throws Exception { + MusicDirectory result; + if ("starred".equals(albumListType)) { + result = service.getStarredList(context, this); + } else if(("genres".equals(albumListType) && ServerInfo.checkServerVersion(context, "1.10.0")) || "years".equals(albumListType)) { + result = service.getAlbumList(albumListType, albumListExtra, size, 0, context, this); + if(result.getChildrenSize() == 0 && "genres".equals(albumListType)) { + SelectDirectoryFragment.this.albumListType = "genres-songs"; + result = service.getSongsByGenre(albumListExtra, size, 0, context, this); + } + } else if("genres".equals(albumListType) || "genres-songs".equals(albumListType)) { + result = service.getSongsByGenre(albumListExtra, size, 0, context, this); + } else { + result = service.getAlbumList(albumListType, size, 0, context, this); + } + return result; + } + }.execute(); + } + + private abstract class LoadTask extends TabBackgroundTask> { + private boolean refresh; + + public LoadTask(boolean refresh) { + super(SelectDirectoryFragment.this); + this.refresh = refresh; + + currentTask = this; + } + + protected abstract MusicDirectory load(MusicService service) throws Exception; + + @Override + protected Pair doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + MusicDirectory dir = load(musicService); + licenseValid = musicService.isLicenseValid(context, this); + + albums = dir.getChildren(true, false); + if(largeAlbums) { + entries = dir.getChildren(false, true); + } else { + entries = dir.getChildren(); + } + + // This isn't really an artist if no albums on it! + if(albums.size() == 0) { + artist = false; + } + + // If artist, we want to load the artist info to use later + if(artist && ServerInfo.hasArtistInfo(context) && !Util.isOffline(context)) { + try { + String artistId; + if(id.indexOf(';') == -1) { + artistId = id; + } else { + artistId = id.substring(0, id.indexOf(';')); + } + + artistInfo = musicService.getArtistInfo(artistId, refresh, false, context, this); + + if(artistInfo == null) { + artistInfoDelayed = artistId; + } + } catch(Exception e) { + Log.w(TAG, "Failed to get Artist Info even though it should be supported"); + } + } + + return new Pair(dir, licenseValid); + } + + @Override + protected void done(Pair result) { + finishLoading(); + currentTask = null; + } + } + + private void finishLoading() { + // Show header if not album list type and not root and not artist + // For Subsonic 5.1+ display a header for artists with getArtistInfo data if it exists + View header = null; + if(albumListType == null && !"root".equals(id) && (!artist || artistInfo != null || artistInfoDelayed != null)) { + header = createHeader(); + + if(header != null && artistInfoDelayed != null) { + final View finalHeader = header.findViewById(R.id.select_album_header); + final View headerProgress = header.findViewById(R.id.header_progress); + + finalHeader.setVisibility(View.INVISIBLE); + headerProgress.setVisibility(View.VISIBLE); + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + artistInfo = musicService.getArtistInfo(artistInfoDelayed, false, true, context, this); + + return null; + } + + @Override + protected void done(Void result) { + /*if(albumList instanceof HeaderGridView) { + HeaderGridView headerGridView = (HeaderGridView) albumList; + headerGridView.invalidateRowHeight(); + ((BaseAdapter) headerGridView.getAdapter()).notifyDataSetChanged(); + }*/ + + setupCoverArt(finalHeader); + setupTextDisplay(finalHeader); + setupButtonEvents(finalHeader); + + finalHeader.setVisibility(View.VISIBLE); + headerProgress.setVisibility(View.GONE); + } + }.execute(); + } + + // Only add header to entry list if we aren't going recreate album grid as root anyways + if(header != null && entryList != null && (!addAlbumHeader || entries.size() > 0)) { + entryList.addHeaderView(header, null, false); + header = null; + } + } + + // Needs to be added here, GB crashes if you to try to remove the header view before adapter is set + if(addAlbumHeader) { + if(entries.size() > 0 || playlistId != null || podcastId != null) { + entryList.addHeaderView(albumList); + } else { + ViewGroup rootGroup = (ViewGroup) rootView.findViewById(R.id.select_album_layout); + albumList = (GridView) context.getLayoutInflater().inflate(R.layout.grid_view, rootGroup, false); + rootGroup.removeView(entryList); + rootGroup.addView(albumList); + + setupScrollList(albumList); + setupAlbumList(); + + // This should only not be null for a artist with only albums + if(header != null) { + HeaderGridView headerGridView = (HeaderGridView) albumList; + headerGridView.addHeaderView(header); + } + } + addAlbumHeader = false; + } + + boolean validData = !entries.isEmpty() || !albums.isEmpty(); + if(!validData) { + setEmpty(true); + } + // Always going to have entries in entryAdapter + entryAdapter = new EntryAdapter(context, getImageLoader(), entries, (podcastId == null)); + ListAdapter listAdapter = entryAdapter; + // Song-only genre needs to always be entry list + infinite adapter + if("genres-songs".equals(albumListType)) { + ViewGroup rootGroup = (ViewGroup) rootView.findViewById(R.id.select_album_layout); + if(rootGroup.findViewById(R.id.gridview) != null && largeAlbums) { + rootGroup.removeView(albumList); + rootGroup.addView(entryList); + } + + listAdapter = new AlbumListAdapter(context, entryAdapter, albumListType, albumListExtra, albumListSize); + } else if(albumListType == null || "starred".equals(albumListType)) { + // Only set standard album adapter if not album list and largeAlbums is true + if(largeAlbums) { + albumList.setAdapter(new AlbumGridAdapter(context, getImageLoader(), albums, !artist)); + } + } else { + // If album list, use infinite adapters for either depending on whether or not largeAlbums is true + if(largeAlbums) { + albumList.setAdapter(new AlbumListAdapter(context, new AlbumGridAdapter(context, getImageLoader(), albums, true), albumListType, albumListExtra, albumListSize)); + } else { + listAdapter = new AlbumListAdapter(context, entryAdapter, albumListType, albumListExtra, albumListSize); + } + } + entryList.setAdapter(listAdapter); + if(validData) { + entryList.setVisibility(View.VISIBLE); + } + context.supportInvalidateOptionsMenu(); + + if(lookupEntry != null) { + for(int i = 0; i < entries.size(); i++) { + if(lookupEntry.equals(entries.get(i).getTitle())) { + entryList.setSelection(i + entryList.getHeaderViewsCount()); + lookupEntry = null; + break; + } + } + } + + Bundle args = getArguments(); + boolean playAll = args.getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false); + if (playAll && !restoredInstance) { + playAll(args.getBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE, false), false); + } + } + + private void setupAlbumList() { + albumList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Entry entry = (Entry) parent.getItemAtPosition(position); + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.getTitle()); + args.putSerializable(Constants.INTENT_EXTRA_NAME_DIRECTORY, entry); + if ("newest".equals(albumListType)) { + args.putBoolean(Constants.INTENT_EXTRA_REFRESH_LISTINGS, true); + } + if(entry.getArtist() == null && entry.getParent() == null) { + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + } + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + }); + + registerForContextMenu(entryList); + registerForContextMenu(albumList); + } + + private void playNow(final boolean shuffle, final boolean append) { + playNow(shuffle, append, false); + } + private void playNow(final boolean shuffle, final boolean append, final boolean playNext) { + if(getSelectedSongs().size() > 0) { + download(append, false, !append, playNext, shuffle); + selectAll(false, false); + } + else { + playAll(shuffle, append); + } + } + private void playAll(final boolean shuffle, final boolean append) { + boolean hasSubFolders = false; + for (int i = 0; i < entryList.getCount(); i++) { + Entry entry = (Entry) entryList.getItemAtPosition(i); + if (entry != null && entry.isDirectory()) { + hasSubFolders = true; + break; + } + } + if(albums.size() > 0) { + hasSubFolders = true; + } + + if (hasSubFolders && (id != null || share != null || "starred".equals(albumListType))) { + downloadRecursively(id, false, append, !append, shuffle, false); + } else if(hasSubFolders && albumListType != null) { + downloadRecursively(albums, shuffle, append); + } else { + selectAll(true, false); + download(append, false, !append, false, shuffle); + selectAll(false, false); + } + } + + private void selectAll(boolean selected, boolean toast) { + int count = entryList.getCount(); + int selectedCount = 0; + for (int i = 0; i < count; i++) { + Entry entry = (Entry) entryList.getItemAtPosition(i); + if (entry != null && !entry.isDirectory() && !entry.isVideo()) { + entryList.setItemChecked(i, selected); + selectedCount++; + } + } + + // Display toast: N tracks selected / N tracks unselected + if (toast) { + int toastResId = selected ? R.string.select_album_n_selected + : R.string.select_album_n_unselected; + Util.toast(context, context.getString(toastResId, selectedCount)); + } + } + + private List getSelectedSongs() { + List songs = new ArrayList(10); + int count = entryList.getCount(); + for (int i = 0; i < count; i++) { + if (entryList.isItemChecked(i)) { + Entry entry = (Entry) entryList.getItemAtPosition(i); + // Don't try to add directories or 1-starred songs + if(!entry.isDirectory() && entry.getRating() != 1) { + songs.add(entry); + } + } + } + return songs; + } + + private List getSelectedIndexes() { + List indexes = new ArrayList(); + + int count = entryList.getCount(); + int headers = entryList.getHeaderViewsCount(); + for (int i = 0; i < count; i++) { + if (entryList.isItemChecked(i)) { + indexes.add(i - headers); + } + } + + return indexes; + } + + private void download(final boolean append, final boolean save, final boolean autoplay, final boolean playNext, final boolean shuffle) { + if (getDownloadService() == null) { + return; + } + + final List songs = getSelectedSongs(); + warnIfStorageUnavailable(); + + // Conditions for using play now button + if(!append && !save && autoplay && !playNext && !shuffle) { + // Call playNow which goes through and tries to use bookmark information + playNow(songs, playlistName, playlistId); + return; + } + + LoadingTask onValid = new LoadingTask(context) { + @Override + protected Void doInBackground() throws Throwable { + if (!append) { + getDownloadService().clear(); + } + + getDownloadService().download(songs, save, autoplay, playNext, shuffle); + if (playlistName != null) { + getDownloadService().setSuggestedPlaylistName(playlistName, playlistId); + } else { + getDownloadService().setSuggestedPlaylistName(null, null); + } + return null; + } + + @Override + protected void done(Void result) { + if (autoplay) { + Util.startActivityWithoutTransition(context, DownloadActivity.class); + } else if (save) { + Util.toast(context, + context.getResources().getQuantityString(R.plurals.select_album_n_songs_downloading, songs.size(), songs.size())); + } else if (append) { + Util.toast(context, + context.getResources().getQuantityString(R.plurals.select_album_n_songs_added, songs.size(), songs.size())); + } + } + }; + + checkLicenseAndTrialPeriod(onValid); + } + private void downloadBackground(final boolean save) { + if(playlistId != null) { + selectAll(true, false); + } + + List songs = getSelectedSongs(); + if(songs.isEmpty()) { + // Get both songs and albums + downloadRecursively(id, save, false, false, false, true); + } else { + downloadBackground(save, songs); + } + } + private void downloadBackground(final boolean save, final List songs) { + if (getDownloadService() == null) { + return; + } + + warnIfStorageUnavailable(); + LoadingTask onValid = new LoadingTask(context) { + @Override + protected Void doInBackground() throws Throwable { + getDownloadService().downloadBackground(songs, save); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getQuantityString(R.plurals.select_album_n_songs_downloading, songs.size(), songs.size())); + } + }; + + checkLicenseAndTrialPeriod(onValid); + } + + private void delete() { + List songs = getSelectedSongs(); + if(songs.isEmpty()) { + selectAll(true, false); + songs = getSelectedSongs(); + + // Also delete all directories + for(Entry album: albums) { + deleteRecursively(album); + } + } + if (getDownloadService() != null) { + getDownloadService().delete(songs); + } + } + + public void removeFromPlaylist(final String id, final String name, final List indexes) { + new LoadingTask(context, true) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.removeFromPlaylist(id, indexes, context, null); + return null; + } + + @Override + protected void done(Void result) { + for(int i = indexes.size() - 1; i >= 0; i--) { + entryList.setItemChecked(indexes.get(i) + 1, false); + entryAdapter.removeAt(indexes.get(i)); + } + entryAdapter.notifyDataSetChanged(); + Util.toast(context, context.getResources().getString(R.string.removed_playlist, indexes.size(), name)); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.updated_playlist_error, name) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + public void downloadAllPodcastEpisodes() { + new LoadingTask(context, true) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + + for(int i = 0; i < entries.size(); i++) { + PodcastEpisode episode = (PodcastEpisode) entries.get(i); + if("skipped".equals(episode.getStatus())) { + musicService.downloadPodcastEpisode(episode.getEpisodeId(), context, null); + } + } + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getString(R.string.select_podcasts_downloading, podcastName)); + } + + @Override + protected void error(Throwable error) { + Util.toast(context, getErrorMessage(error), false); + } + }.execute(); + } + + public void downloadPodcastEpisode(final PodcastEpisode episode) { + new LoadingTask(context, true) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.downloadPodcastEpisode(episode.getEpisodeId(), context, null); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getString(R.string.select_podcasts_downloading, episode.getTitle())); + } + + @Override + protected void error(Throwable error) { + Util.toast(context, getErrorMessage(error), false); + } + }.execute(); + } + + public void deletePodcastEpisode(final PodcastEpisode episode) { + Util.confirmDialog(context, R.string.common_delete, episode.getTitle(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, true) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deletePodcastEpisode(episode.getEpisodeId(), episode.getParent(), null, context); + if (getDownloadService() != null) { + List episodeList = new ArrayList(1); + episodeList.add(episode); + getDownloadService().delete(episodeList); + } + return null; + } + + @Override + protected void done(Void result) { + entries.remove(episode); + entryAdapter.notifyDataSetChanged(); + } + + @Override + protected void error(Throwable error) { + Log.w(TAG, "Failed to delete podcast episode", error); + Util.toast(context, getErrorMessage(error), false); + } + }.execute(); + } + }); + } + + public void unstarSelected() { + List selected = getSelectedSongs(); + if(selected.size() == 0) { + selected = entries; + } + if(selected.size() == 0) { + return; + } + final List unstar = new ArrayList(); + unstar.addAll(selected); + + new LoadingTask(context, true) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + List entries = new ArrayList(); + List artists = new ArrayList(); + List albums = new ArrayList(); + for(Entry entry: unstar) { + if(entry.isDirectory()) { + if(entry.isAlbum()) { + albums.add(entry); + } else { + artists.add(entry); + } + } else { + entries.add(entry); + } + } + musicService.setStarred(entries, artists, albums, false, this, context); + + for(Entry entry: unstar) { + new EntryInstanceUpdater(entry) { + @Override + public void update(Entry found) { + found.setStarred(false); + } + }.execute(); + } + + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getString(R.string.starring_content_unstarred, Integer.toString(unstar.size()))); + + for(Entry entry: unstar) { + entries.remove(entry); + } + entryAdapter.notifyDataSetChanged(); + selectAll(false, false); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.starring_content_error, Integer.toString(unstar.size())) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + private void checkLicenseAndTrialPeriod(LoadingTask onValid) { + if (licenseValid) { + onValid.execute(); + return; + } + + int trialDaysLeft = Util.getRemainingTrialDays(context); + Log.i(TAG, trialDaysLeft + " trial days left."); + + if (trialDaysLeft == 0) { + showDonationDialog(trialDaysLeft, null); + } else if (trialDaysLeft < Constants.FREE_TRIAL_DAYS / 2) { + showDonationDialog(trialDaysLeft, onValid); + } else { + Util.toast(context, context.getResources().getString(R.string.select_album_not_licensed, trialDaysLeft)); + onValid.execute(); + } + } + + private void showDonationDialog(int trialDaysLeft, final LoadingTask onValid) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setIcon(android.R.drawable.ic_dialog_info); + + if (trialDaysLeft == 0) { + builder.setTitle(R.string.select_album_donate_dialog_0_trial_days_left); + } else { + builder.setTitle(context.getResources().getQuantityString(R.plurals.select_album_donate_dialog_n_trial_days_left, + trialDaysLeft, trialDaysLeft)); + } + + builder.setMessage(R.string.select_album_donate_dialog_message); + + builder.setPositiveButton(R.string.select_album_donate_dialog_now, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(Constants.DONATION_URL))); + } + }); + + builder.setNegativeButton(R.string.select_album_donate_dialog_later, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + if (onValid != null) { + onValid.execute(); + } + } + }); + + builder.create().show(); + } + + private void showTopTracks() { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(getArguments()); + args.putBoolean(Constants.INTENT_EXTRA_TOP_TRACKS, true); + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + + private void setShowAll() { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(getArguments()); + args.putBoolean(Constants.INTENT_EXTRA_SHOW_ALL, true); + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + + private void showSimilarArtists(String artistId) { + SubsonicFragment fragment = new SimilarArtistFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ARTIST, artistId); + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + + private void startArtistRadio(final String artistId) { + new LoadingTask(context) { + @Override + protected Void doInBackground() throws Throwable { + DownloadService downloadService = getDownloadService(); + downloadService.setArtistRadio(artistId); + if(downloadService.size() == 0) { + Log.e(TAG, "Failed to create artist radio"); + throw new Exception("Failed to create artist radio"); + } + return null; + } + + @Override + protected void done(Void result) { + Util.startActivityWithoutTransition(context, DownloadActivity.class); + } + }.execute(); + } + + private View createHeader() { + View header = entryList.findViewById(R.id.select_album_header_wrapper); + boolean add = false; + if(header == null) { + header = LayoutInflater.from(context).inflate(R.layout.select_album_header, entryList, false); + add = true; + } + + setupCoverArt(header); + setupTextDisplay(header); + + if(add) { + setupButtonEvents(header); + } + + if(add) { + return header; + } else { + return null; + } + } + + private void setupCoverArt(View header) { + final ImageLoader imageLoader = getImageLoader(); + View coverArtView = header.findViewById(R.id.select_album_art); + + // Try a few times to get a random cover art + if(artistInfo != null) { + final String url = artistInfo.getImageUrl(); + coverArtView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (url == null) { + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + ImageView fullScreenView = new ImageView(context); + imageLoader.loadImage(fullScreenView, url, true); + builder.setCancelable(true); + + AlertDialog imageDialog = builder.create(); + // Set view here with unecessary 0's to remove top/bottom border + imageDialog.setView(fullScreenView, 0, 0, 0, 0); + imageDialog.show(); + } + }); + imageLoader.loadImage(coverArtView, url, false); + } else if(entries.size() > 0) { + Entry coverArt = null; + for (int i = 0; (i < 3) && (coverArt == null || coverArt.getCoverArt() == null); i++) { + coverArt = entries.get(random.nextInt(entries.size())); + } + + final Entry albumRep = coverArt; + coverArtView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (albumRep.getCoverArt() == null) { + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + ImageView fullScreenView = new ImageView(context); + imageLoader.loadImage(fullScreenView, albumRep, true, true); + builder.setCancelable(true); + + AlertDialog imageDialog = builder.create(); + // Set view here with unecessary 0's to remove top/bottom border + imageDialog.setView(fullScreenView, 0, 0, 0, 0); + imageDialog.show(); + } + }); + imageLoader.loadImage(coverArtView, albumRep, false, true); + } + } + private void setupTextDisplay(final View header) { + final TextView titleView = (TextView) header.findViewById(R.id.select_album_title); + if(playlistName != null) { + titleView.setText(playlistName); + } else if(podcastName != null) { + titleView.setText(podcastName); + titleView.setPadding(0, 6, 4, 8); + } else if(name != null) { + titleView.setText(name); + + if(artistInfo != null) { + titleView.setPadding(0, 6, 4, 8); + } + } else if(share != null) { + titleView.setVisibility(View.GONE); + } + + int songCount = 0; + + Set artists = new HashSet(); + Set years = new HashSet(); + Integer totalDuration = 0; + for (Entry entry : entries) { + if (!entry.isDirectory()) { + songCount++; + if (entry.getArtist() != null) { + artists.add(entry.getArtist()); + } + if(entry.getYear() != null) { + years.add(entry.getYear()); + } + Integer duration = entry.getDuration(); + if(duration != null) { + totalDuration += duration; + } + } + } + + final TextView artistView = (TextView) header.findViewById(R.id.select_album_artist); + if(podcastDescription != null || artistInfo != null) { + artistView.setVisibility(View.VISIBLE); + String text = podcastDescription != null ? podcastDescription : artistInfo.getBiography(); + Spanned spanned = null; + if(text != null) { + spanned = Html.fromHtml(text); + } + artistView.setText(spanned); + artistView.setSingleLine(false); + final int minLines = context.getResources().getInteger(R.integer.TextDescriptionLength); + artistView.setLines(minLines); + artistView.setTextAppearance(context, android.R.style.TextAppearance_Small); + + final Spanned spannedText = spanned; + artistView.setOnClickListener(new View.OnClickListener() { + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + @Override + public void onClick(View v) { + if(artistView.getMaxLines() == minLines) { + // Use LeadingMarginSpan2 to try to make text flow around image + Display display = context.getWindowManager().getDefaultDisplay(); + ImageView coverArtView = (ImageView) header.findViewById(R.id.select_album_art); + coverArtView.measure(display.getWidth(), display.getHeight()); + + int height, width; + ViewGroup.MarginLayoutParams vlp = (ViewGroup.MarginLayoutParams) coverArtView.getLayoutParams(); + if(coverArtView.getDrawable() != null) { + height = coverArtView.getMeasuredHeight() + coverArtView.getPaddingBottom(); + width = coverArtView.getWidth() + coverArtView.getPaddingRight(); + } else { + height = coverArtView.getHeight(); + width = coverArtView.getWidth() + coverArtView.getPaddingRight(); + } + float textLineHeight = artistView.getPaint().getTextSize(); + int lines = (int) Math.ceil(height / textLineHeight); + + SpannableString ss = new SpannableString(spannedText); + ss.setSpan(new MyLeadingMarginSpan2(lines, width), 0, ss.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + View linearLayout = header.findViewById(R.id.select_album_text_layout); + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) linearLayout.getLayoutParams(); + int[]rules = params.getRules(); + rules[RelativeLayout.RIGHT_OF] = 0; + params.leftMargin = vlp.rightMargin; + + artistView.setText(ss); + artistView.setMaxLines(100); + + vlp = (ViewGroup.MarginLayoutParams) titleView.getLayoutParams(); + vlp.leftMargin = width; + } else { + artistView.setMaxLines(minLines); + } + + if(albumList instanceof HeaderGridView) { + HeaderGridView headerGridView = (HeaderGridView) albumList; + ((BaseAdapter) headerGridView.getAdapter()).notifyDataSetChanged(); + } + } + }); + artistView.setMovementMethod(LinkMovementMethod.getInstance()); + } else if(topTracks) { + artistView.setText(R.string.menu_top_tracks); + artistView.setVisibility(View.VISIBLE); + } else if(showAll) { + artistView.setText(R.string.menu_show_all); + artistView.setVisibility(View.VISIBLE); + } else if (artists.size() == 1) { + String artistText = artists.iterator().next(); + if(years.size() == 1) { + artistText += " - " + years.iterator().next(); + } + artistView.setText(artistText); + artistView.setVisibility(View.VISIBLE); + } else { + artistView.setVisibility(View.GONE); + } + + TextView songCountView = (TextView) header.findViewById(R.id.select_album_song_count); + TextView songLengthView = (TextView) header.findViewById(R.id.select_album_song_length); + if(podcastDescription != null || artistInfo != null) { + songCountView.setVisibility(View.GONE); + songLengthView.setVisibility(View.GONE); + } else { + String s = context.getResources().getQuantityString(R.plurals.select_album_n_songs, songCount, songCount); + songCountView.setText(s.toUpperCase()); + songLengthView.setText(Util.formatDuration(totalDuration)); + } + } + private void setupButtonEvents(View header) { + ImageView shareButton = (ImageView) header.findViewById(R.id.select_album_share); + if(share != null || podcastId != null || !Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_MENU_SHARED, true) || Util.isOffline(context) || !UserUtil.canShare() || artistInfo != null) { + shareButton.setVisibility(View.GONE); + } else { + shareButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + createShare(SelectDirectoryFragment.this.entries); + } + }); + } + + final ImageButton starButton = (ImageButton) header.findViewById(R.id.select_album_star); + if(directory != null && Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_MENU_STAR, true) && artistInfo == null) { + starButton.setImageResource(directory.isStarred() ? android.R.drawable.btn_star_big_on : android.R.drawable.btn_star_big_off); + starButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + toggleStarred(directory, new OnStarChange() { + @Override + void starChange(boolean starred) { + starButton.setImageResource(directory.isStarred() ? android.R.drawable.btn_star_big_on : android.R.drawable.btn_star_big_off); + } + }); + } + }); + } else { + starButton.setVisibility(View.GONE); + } + + View ratingBarWrapper = header.findViewById(R.id.select_album_rate_wrapper); + final RatingBar ratingBar = (RatingBar) header.findViewById(R.id.select_album_rate); + if(directory != null && Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_MENU_RATING, true) && !Util.isOffline(context) && artistInfo == null) { + ratingBar.setRating(directory.getRating()); + ratingBarWrapper.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + setRating(directory, new OnRatingChange() { + @Override + void ratingChange(int rating) { + ratingBar.setRating(directory.getRating()); + } + }); + } + }); + } else { + ratingBar.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectGenreFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectGenreFragment.java new file mode 100644 index 00000000..2d310172 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectGenreFragment.java @@ -0,0 +1,71 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.fragments; + +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.adapter.GenreAdapter; + +import java.util.List; + +public class SelectGenreFragment extends SelectListFragment { + private static final String TAG = SelectGenreFragment.class.getSimpleName(); + + @Override + public int getOptionsMenu() { + return R.menu.empty; + } + + @Override + public ArrayAdapter getAdapter(List objs) { + return new GenreAdapter(context, objs); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + return musicService.getGenres(refresh, context, listener); + } + + @Override + public int getTitleResource() { + return R.string.main_albums_genres; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Genre genre = (Genre) parent.getItemAtPosition(position); + + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, "genres"); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 20); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0); + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_EXTRA, genre.getName()); + fragment.setArguments(args); + + replaceFragment(fragment); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectListFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectListFragment.java new file mode 100644 index 00000000..6f73f6e8 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectListFragment.java @@ -0,0 +1,163 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.fragments; + +import android.os.Bundle; +import android.support.v4.widget.SwipeRefreshLayout; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.TabBackgroundTask; + +public abstract class SelectListFragment extends SubsonicFragment implements AdapterView.OnItemClickListener { + private static final String TAG = SelectListFragment.class.getSimpleName(); + protected ListView listView; + protected ArrayAdapter adapter; + protected BackgroundTask> currentTask; + protected List objects; + protected boolean serialize = true; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + if(bundle != null && serialize) { + objects = (List) bundle.getSerializable(Constants.FRAGMENT_LIST); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if(serialize) { + outState.putSerializable(Constants.FRAGMENT_LIST, (Serializable) objects); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + rootView = inflater.inflate(R.layout.abstract_list_fragment, container, false); + + refreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.refresh_layout); + refreshLayout.setOnRefreshListener(this); + + listView = (ListView)rootView.findViewById(R.id.fragment_list); + listView.setOnItemClickListener(this); + setupScrollList(listView); + registerForContextMenu(listView); + + if(objects == null) { + refresh(false); + } else { + listView.setAdapter(adapter = getAdapter(objects)); + } + + return rootView; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + if(!primaryFragment) { + return; + } + + menuInflater.inflate(getOptionsMenu(), menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return super.onOptionsItemSelected(item); + } + + @Override + protected void refresh(final boolean refresh) { + int titleRes = getTitleResource(); + if(titleRes != 0) { + setTitle(getTitleResource()); + } + listView.setVisibility(View.GONE); + + // Cancel current running task before starting another one + if(currentTask != null) { + currentTask.cancel(); + } + + currentTask = new TabBackgroundTask>(this) { + @Override + protected List doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + + objects = new ArrayList(); + + try { + objects = getObjects(musicService, refresh, this); + } catch (Exception x) { + Log.e(TAG, "Failed to load", x); + } + + return objects; + } + + @Override + protected void done(List result) { + if (result != null && !result.isEmpty()) { + // Toggle fast scroll to get around issue when length of list changes + listView.setFastScrollEnabled(false); + listView.setAdapter(adapter = getAdapter(result)); + listView.setFastScrollEnabled(true); + + onFinishRefresh(); + listView.setVisibility(View.VISIBLE); + } else { + setEmpty(true); + } + + currentTask = null; + } + }; + currentTask.execute(); + } + + public abstract int getOptionsMenu(); + public abstract ArrayAdapter getAdapter(List objs); + public abstract List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception; + public abstract int getTitleResource(); + + public void onFinishRefresh() { + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectPlaylistFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectPlaylistFragment.java new file mode 100644 index 00000000..3d7e664f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectPlaylistFragment.java @@ -0,0 +1,303 @@ +package github.daneren2005.dsub.fragments; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.support.v4.app.FragmentTransaction; +import android.view.ContextMenu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.EditText; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.CacheCleaner; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.PlaylistAdapter; + +import java.util.List; + +public class SelectPlaylistFragment extends SelectListFragment { + private static final String TAG = SelectPlaylistFragment.class.getSimpleName(); + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + MenuInflater inflater = context.getMenuInflater(); + if (Util.isOffline(context)) { + inflater.inflate(R.menu.select_playlist_context_offline, menu); + } + else { + inflater.inflate(R.menu.select_playlist_context, menu); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + Playlist playlist = (Playlist) listView.getItemAtPosition(info.position); + if(SyncUtil.isSyncedPlaylist(context, playlist.getId())) { + menu.removeItem(R.id.playlist_menu_sync); + } else { + menu.removeItem(R.id.playlist_menu_stop_sync); + } + + if(!ServerInfo.checkServerVersion(context, "1.8")) { + menu.removeItem(R.id.playlist_update_info); + } else if(playlist.getPublic() != null && playlist.getPublic() == true && playlist.getId().indexOf(".m3u") == -1 && !UserUtil.getCurrentUsername(context).equals(playlist.getOwner())) { + menu.removeItem(R.id.playlist_update_info); + menu.removeItem(R.id.playlist_menu_delete); + } + } + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Playlist playlist = (Playlist) listView.getItemAtPosition(info.position); + + SubsonicFragment fragment; + Bundle args; + FragmentTransaction trans; + switch (menuItem.getItemId()) { + case R.id.playlist_menu_download: + downloadPlaylist(playlist.getId(), playlist.getName(), false, true, false, false, true); + break; + case R.id.playlist_menu_sync: + syncPlaylist(playlist); + break; + case R.id.playlist_menu_stop_sync: + stopSyncPlaylist(playlist); + break; + case R.id.playlist_menu_play_now: + fragment = new SelectDirectoryFragment(); + args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName()); + args.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true); + fragment.setArguments(args); + + replaceFragment(fragment); + break; + case R.id.playlist_menu_play_shuffled: + fragment = new SelectDirectoryFragment(); + args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName()); + args.putBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE, true); + args.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true); + fragment.setArguments(args); + + replaceFragment(fragment); + break; + case R.id.playlist_menu_delete: + deletePlaylist(playlist); + break; + case R.id.playlist_info: + displayPlaylistInfo(playlist); + break; + case R.id.playlist_update_info: + updatePlaylistInfo(playlist); + break; + default: + return false; + } + return true; + } + + @Override + public int getOptionsMenu() { + return R.menu.abstract_top_menu; + } + + @Override + public ArrayAdapter getAdapter(List playlists) { + return new PlaylistAdapter(context, playlists); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + List playlists = musicService.getPlaylists(refresh, context, listener); + if(!Util.isOffline(context) && refresh) { + new CacheCleaner(context, getDownloadService()).cleanPlaylists(playlists); + } + return playlists; + } + + @Override + public int getTitleResource() { + return R.string.playlist_label; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Playlist playlist = (Playlist) parent.getItemAtPosition(position); + + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName()); + if(ServerInfo.checkServerVersion(context, "1.8") && (playlist.getOwner() != null && playlist.getOwner().equals(UserUtil.getCurrentUsername(context)) || playlist.getId().indexOf(".m3u") != -1)) { + args.putBoolean(Constants.INTENT_EXTRA_NAME_PLAYLIST_OWNER, true); + } + fragment.setArguments(args); + + replaceFragment(fragment); + } + + private void deletePlaylist(final Playlist playlist) { + Util.confirmDialog(context, R.string.common_delete, playlist.getName(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deletePlaylist(playlist.getId(), context, null); + SyncUtil.removeSyncedPlaylist(context, playlist.getId()); + return null; + } + + @Override + protected void done(Void result) { + adapter.remove(playlist); + adapter.notifyDataSetChanged(); + Util.toast(context, context.getResources().getString(R.string.menu_deleted_playlist, playlist.getName())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.menu_deleted_playlist_error, playlist.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + }); + } + + private void displayPlaylistInfo(final Playlist playlist) { + String message = "Owner: " + playlist.getOwner() + "\nComments: " + + ((playlist.getComment() == null) ? "" : playlist.getComment()) + + "\nSong Count: " + playlist.getSongCount() + + ((playlist.getPublic() == null) ? "" : ("\nPublic: " + playlist.getPublic())) + + "\nCreation Date: " + playlist.getCreated().replace('T', ' '); + Util.info(context, playlist.getName(), message); + } + + private void updatePlaylistInfo(final Playlist playlist) { + View dialogView = context.getLayoutInflater().inflate(R.layout.update_playlist, null); + final EditText nameBox = (EditText)dialogView.findViewById(R.id.get_playlist_name); + final EditText commentBox = (EditText)dialogView.findViewById(R.id.get_playlist_comment); + final CheckBox publicBox = (CheckBox)dialogView.findViewById(R.id.get_playlist_public); + + nameBox.setText(playlist.getName()); + commentBox.setText(playlist.getComment()); + Boolean pub = playlist.getPublic(); + if(pub == null) { + publicBox.setEnabled(false); + } else { + publicBox.setChecked(pub); + } + + new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle(R.string.playlist_update_info) + .setView(dialogView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + String name = nameBox.getText().toString(); + String comment = commentBox.getText().toString(); + boolean isPublic = publicBox.isChecked(); + + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.updatePlaylist(playlist.getId(), name, comment, isPublic, context, null); + + playlist.setName(name); + playlist.setComment(comment); + playlist.setPublic(isPublic); + + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getString(R.string.playlist_updated_info, playlist.getName())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.playlist_updated_info_error, playlist.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + }) + .setNegativeButton(R.string.common_cancel, null) + .show(); + } + + private void syncPlaylist(Playlist playlist) { + SyncUtil.addSyncedPlaylist(context, playlist.getId()); + downloadPlaylist(playlist.getId(), playlist.getName(), true, true, false, false, true); + } + + private void stopSyncPlaylist(final Playlist playlist) { + SyncUtil.removeSyncedPlaylist(context, playlist.getId()); + + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + // Unpin all of the songs in playlist + MusicService musicService = MusicServiceFactory.getMusicService(context); + MusicDirectory root = musicService.getPlaylist(true, playlist.getId(), playlist.getName(), context, this); + for(MusicDirectory.Entry entry: root.getChildren()) { + DownloadFile file = new DownloadFile(context, entry, false); + file.unpin(); + } + + return null; + } + + @Override + protected void done(Void result) { + + } + }.execute(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectPodcastsFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectPodcastsFragment.java new file mode 100644 index 00000000..3a564f1c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectPodcastsFragment.java @@ -0,0 +1,308 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.fragments; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.PodcastChannelAdapter; + +import java.util.ArrayList; +import java.util.List; + +/** + * + * @author Scott + */ +public class SelectPodcastsFragment extends SelectListFragment { + private static final String TAG = SelectPodcastsFragment.class.getSimpleName(); + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(super.onOptionsItemSelected(item)) { + return true; + } + + switch (item.getItemId()) { + case R.id.menu_check: + refreshPodcasts(); + break; + case R.id.menu_add_podcast: + addNewPodcast(); + break; + } + + return false; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + android.view.MenuInflater inflater = context.getMenuInflater(); + if(!Util.isOffline(context) && UserUtil.canPodcast()) { + inflater.inflate(R.menu.select_podcasts_context, menu); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + PodcastChannel podcast = (PodcastChannel) listView.getItemAtPosition(info.position); + if(SyncUtil.isSyncedPodcast(context, podcast.getId())) { + menu.removeItem(R.id.podcast_menu_sync); + } else { + menu.removeItem(R.id.podcast_menu_stop_sync); + } + } else { + inflater.inflate(R.menu.select_podcasts_context_offline, menu); + } + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + PodcastChannel channel = (PodcastChannel) listView.getItemAtPosition(info.position); + + switch (menuItem.getItemId()) { + case R.id.podcast_menu_sync: + syncPodcast(channel); + break; + case R.id.podcast_menu_stop_sync: + stopSyncPodcast(channel); + break; + case R.id.podcast_channel_info: + displayPodcastInfo(channel); + break; + case R.id.podcast_channel_delete: + deletePodcast(channel); + break; + } + + return true; + } + + @Override + public int getOptionsMenu() { + return (UserUtil.canPodcast() && !Util.isOffline(context)) ? R.menu.select_podcasts : R.menu.abstract_top_menu; + } + + @Override + public ArrayAdapter getAdapter(List channels) { + return new PodcastChannelAdapter(context, channels); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + return musicService.getPodcastChannels(refresh, context, listener); + } + + @Override + public int getTitleResource() { + return R.string.button_bar_podcasts; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + PodcastChannel channel = (PodcastChannel) parent.getItemAtPosition(position); + + if("error".equals(channel.getStatus())) { + Util.toast(context, context.getResources().getString(R.string.select_podcasts_invalid_podcast_channel, channel.getErrorMessage() == null ? "error" : channel.getErrorMessage())); + } else if("downloading".equals(channel.getStatus())) { + Util.toast(context, R.string.select_podcasts_initializing); + } else { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_PODCAST_ID, channel.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_PODCAST_NAME, channel.getName()); + args.putString(Constants.INTENT_EXTRA_NAME_PODCAST_DESCRIPTION, channel.getDescription()); + fragment.setArguments(args); + + replaceFragment(fragment); + } + } + + public void refreshPodcasts() { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.refreshPodcasts(context, null); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, R.string.select_podcasts_refreshing); + } + + @Override + protected void error(Throwable error) { + Util.toast(context, getErrorMessage(error), false); + } + }.execute(); + } + + private void addNewPodcast() { + View dialogView = context.getLayoutInflater().inflate(R.layout.create_podcast, null); + final TextView urlBox = (TextView) dialogView.findViewById(R.id.create_podcast_url); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.menu_add_podcast) + .setView(dialogView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + addNewPodcast(urlBox.getText().toString()); + } + }) + .setNegativeButton(R.string.common_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }) + .setCancelable(true); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + private void addNewPodcast(final String url) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.createPodcastChannel(url, context, null); + return null; + } + + @Override + protected void done(Void result) { + refresh(); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.select_podcasts_created_error) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + private void displayPodcastInfo(final PodcastChannel channel) { + String message = ((channel.getName()) == null ? "" : "Title: " + channel.getName()) + + "\nURL: " + channel.getUrl() + + "\nStatus: " + channel.getStatus() + + ((channel.getErrorMessage()) == null ? "" : "\nError Message: " + channel.getErrorMessage()) + + ((channel.getDescription()) == null ? "" : "\n\nDescription: " + channel.getDescription()); + + Util.info(context, channel.getName(), message); + } + + private void deletePodcast(final PodcastChannel channel) { + Util.confirmDialog(context, R.string.common_delete, channel.getName(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deletePodcastChannel(channel.getId(), context, null); + stopSyncPodcast(channel); + return null; + } + + @Override + protected void done(Void result) { + adapter.remove(channel); + adapter.notifyDataSetChanged(); + Util.toast(context, context.getResources().getString(R.string.select_podcasts_deleted, channel.getName())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.select_podcasts_deleted_error, channel.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + }); + } + + private void syncPodcast(final PodcastChannel podcast) { + new LoadingTask(context, false) { + @Override + protected MusicDirectory doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.getPodcastEpisodes(true, podcast.getId(), context, this); + } + + @Override + protected void done(MusicDirectory result) { + List existingEpisodes = new ArrayList(); + for(MusicDirectory.Entry entry: result.getChildren()) { + String id = entry.getId(); + if(id != null) { + existingEpisodes.add(entry.getId()); + } + } + + SyncUtil.addSyncedPodcast(context, podcast.getId(), existingEpisodes); + } + }.execute(); + } + + private void stopSyncPodcast(PodcastChannel podcast) { + SyncUtil.removeSyncedPodcast(context, podcast.getId()); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectShareFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectShareFragment.java new file mode 100644 index 00000000..07cd3bef --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectShareFragment.java @@ -0,0 +1,216 @@ +package github.daneren2005.dsub.fragments; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.DatePicker; +import android.widget.EditText; + +import java.util.Date; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.ShareAdapter; + +/** + * Created by Scott on 12/28/13. + */ +public class SelectShareFragment extends SelectListFragment { + private static final String TAG = SelectShareFragment.class.getSimpleName(); + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + android.view.MenuInflater inflater = context.getMenuInflater(); + inflater.inflate(R.menu.select_share_context, menu); + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Share share = (Share) listView.getItemAtPosition(info.position); + + switch (menuItem.getItemId()) { + case R.id.share_menu_share: + shareExternal(share); + break; + case R.id.share_menu_info: + displayShareInfo(share); + break; + case R.id.share_menu_delete: + deleteShare(share); + break; + case R.id.share_update_info: + updateShareInfo(share); + break; + } + + return true; + } + + @Override + public int getOptionsMenu() { + return R.menu.abstract_top_menu; + } + + @Override + public ArrayAdapter getAdapter(List objs) { + return new ShareAdapter(context, objs); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + return musicService.getShares(context, listener); + } + + @Override + public int getTitleResource() { + return R.string.button_bar_shares; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Share share = (Share) parent.getItemAtPosition(position); + + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putSerializable(Constants.INTENT_EXTRA_NAME_SHARE, share); + fragment.setArguments(args); + + replaceFragment(fragment); + } + + private void displayShareInfo(final Share share) { + String message = context.getResources().getString(R.string.share_info, + share.getUsername(), (share.getDescription() != null) ? share.getDescription() : "", share.getUrl(), + Util.formatDate(share.getCreated()), Util.formatDate(share.getLastVisited()), Util.formatDate(share.getExpires()), share.getVisitCount()); + Util.info(context, share.getName(), message); + } + + private void updateShareInfo(final Share share) { + View dialogView = context.getLayoutInflater().inflate(R.layout.update_share, null); + final EditText nameBox = (EditText)dialogView.findViewById(R.id.get_share_name); + final DatePicker expireBox = (DatePicker)dialogView.findViewById(R.id.get_share_expire); + final CheckBox noExpiresBox = (CheckBox)dialogView.findViewById(R.id.get_share_no_expire); + + nameBox.setText(share.getDescription()); + Date expires = share.getExpires(); + if(expires != null) { + expireBox.updateDate(expires.getYear() + 1900, expires.getMonth(), expires.getDate()); + } + + boolean noExpires = share.getExpires() == null; + if(noExpires) { + expireBox.setEnabled(false); + noExpiresBox.setChecked(true); + } + + noExpiresBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + expireBox.setEnabled(!isChecked); + } + }); + + new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle(R.string.playlist_update_info) + .setView(dialogView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + Long expiresIn = 0L; + if (!noExpiresBox.isChecked()) { + Date expires = new Date(expireBox.getYear() - 1900, expireBox.getMonth(), expireBox.getDayOfMonth()); + expiresIn = expires.getTime(); + } + + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.updateShare(share.getId(), nameBox.getText().toString(), expiresIn, context, null); + return null; + } + + @Override + protected void done(Void result) { + refresh(); + Util.toast(context, context.getResources().getString(R.string.share_updated_info, share.getName())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.share_updated_info_error, share.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + }) + .setNegativeButton(R.string.common_cancel, null) + .show(); + } + + private void deleteShare(final Share share) { + Util.confirmDialog(context, R.string.common_delete, share.getName(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deleteShare(share.getId(), context, null); + return null; + } + + @Override + protected void done(Void result) { + adapter.remove(share); + adapter.notifyDataSetChanged(); + Util.toast(context, context.getResources().getString(R.string.share_deleted, share.getName())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.share_deleted_error, share.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + }); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectVideoFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectVideoFragment.java new file mode 100644 index 00000000..b4d34ff9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectVideoFragment.java @@ -0,0 +1,82 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.view.ContextMenu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.adapter.EntryAdapter; + +public class SelectVideoFragment extends SelectListFragment { + @Override + public int getOptionsMenu() { + return R.menu.empty; + } + + @Override + public ArrayAdapter getAdapter(List objs) { + return new EntryAdapter(context, null, objs, false); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + MusicDirectory dir = musicService.getVideos(refresh, context, listener); + return dir.getChildren(); + } + + @Override + public int getTitleResource() { + return R.string.main_videos; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + MusicDirectory.Entry entry = (MusicDirectory.Entry) parent.getItemAtPosition(position); + playVideo(entry); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + Object entry = listView.getItemAtPosition(info.position); + + onCreateContextMenu(menu, view, menuInfo, entry); + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if (menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Object entry = listView.getItemAtPosition(info.position); + + return onContextItemSelected(menuItem, entry); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SelectYearFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SelectYearFragment.java new file mode 100644 index 00000000..dc19acad --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SelectYearFragment.java @@ -0,0 +1,78 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus +*/ +package github.daneren2005.dsub.fragments; + +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; + +/** + * Created by Scott on 12/23/13. + */ +public class SelectYearFragment extends SelectListFragment { + + @Override + public int getOptionsMenu() { + return R.menu.empty; + } + + @Override + public ArrayAdapter getAdapter(List objs) { + return new ArrayAdapter(context, android.R.layout.simple_list_item_1, objs); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + List decades = new ArrayList(); + for(int i = 2010; i >= 1920; i -= 10) { + decades.add(i); + } + + return decades; + } + + @Override + public int getTitleResource() { + return R.string.main_albums_year; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Integer decade = (Integer) parent.getItemAtPosition(position); + + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, "years"); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 20); + args.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0); + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_EXTRA, Integer.toString(decade)); + fragment.setArguments(args); + + replaceFragment(fragment); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SettingsFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SettingsFragment.java new file mode 100644 index 00000000..3be21a67 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SettingsFragment.java @@ -0,0 +1,724 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.accounts.Account; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.preference.CheckBoxPreference; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceScreen; +import android.text.InputType; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URL; +import java.text.DecimalFormat; +import java.util.LinkedHashMap; +import java.util.Map; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.HeadphoneListenerService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.view.ErrorDialog; + +public class SettingsFragment extends PreferenceCompatFragment implements SharedPreferences.OnSharedPreferenceChangeListener { + private final static String TAG = SettingsFragment.class.getSimpleName(); + private final Map serverSettings = new LinkedHashMap(); + private boolean testingConnection; + private ListPreference theme; + private ListPreference maxBitrateWifi; + private ListPreference maxBitrateMobile; + private ListPreference maxVideoBitrateWifi; + private ListPreference maxVideoBitrateMobile; + private ListPreference networkTimeout; + private EditTextPreference cacheLocation; + private ListPreference preloadCountWifi; + private ListPreference preloadCountMobile; + private ListPreference tempLoss; + private ListPreference pauseDisconnect; + private Preference addServerPreference; + private PreferenceCategory serversCategory; + private ListPreference videoPlayer; + private ListPreference syncInterval; + private CheckBoxPreference syncEnabled; + private CheckBoxPreference syncWifi; + private CheckBoxPreference syncNotification; + private CheckBoxPreference syncStarred; + private CheckBoxPreference syncMostRecent; + private CheckBoxPreference replayGain; + private ListPreference replayGainType; + private Preference replayGainBump; + private Preference replayGainUntagged; + private String internalSSID; + private String internalSSIDDisplay; + private EditTextPreference cacheSize; + private ListPreference openToTab; + + private int serverCount = 3; + private SharedPreferences settings; + private DecimalFormat megabyteFromat; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + View root = super.onCreateView(inflater, container, bundle); + + this.setTitle(getResources().getString(R.string.settings_title)); + initSettings(); + + return root; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + SharedPreferences prefs = Util.getPreferences(context); + prefs.unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + // Random error I have no idea how to reproduce + if(sharedPreferences == null) { + return; + } + + update(); + + if (Constants.PREFERENCES_KEY_HIDE_MEDIA.equals(key)) { + setHideMedia(sharedPreferences.getBoolean(key, false)); + } + else if (Constants.PREFERENCES_KEY_MEDIA_BUTTONS.equals(key)) { + setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true)); + } + else if (Constants.PREFERENCES_KEY_CACHE_LOCATION.equals(key)) { + setCacheLocation(sharedPreferences.getString(key, "")); + } + else if (Constants.PREFERENCES_KEY_SLEEP_TIMER_DURATION.equals(key)){ + DownloadService downloadService = DownloadService.getInstance(); + downloadService.setSleepTimerDuration(Integer.parseInt(sharedPreferences.getString(key, "60"))); + } + else if(Constants.PREFERENCES_KEY_SYNC_MOST_RECENT.equals(key)) { + SyncUtil.removeMostRecentSyncFiles(context); + } else if(Constants.PREFERENCES_KEY_REPLAY_GAIN.equals(key) || Constants.PREFERENCES_KEY_REPLAY_GAIN_BUMP.equals(key) || Constants.PREFERENCES_KEY_REPLAY_GAIN_UNTAGGED.equals(key)) { + DownloadService downloadService = DownloadService.getInstance(); + if(downloadService != null) { + downloadService.reapplyVolume(); + } + } else if(Constants.PREFERENCES_KEY_START_ON_HEADPHONES.equals(key)) { + Intent serviceIntent = new Intent(); + serviceIntent.setClassName(context.getPackageName(), HeadphoneListenerService.class.getName()); + + if(sharedPreferences.getBoolean(key, false)) { + context.startService(serviceIntent); + } else { + context.stopService(serviceIntent); + } + } + + scheduleBackup(); + } + + private void initSettings() { + internalSSID = Util.getSSID(context); + if(internalSSID == null) { + internalSSID = ""; + } + internalSSIDDisplay = context.getResources().getString(R.string.settings_server_local_network_ssid_hint, internalSSID); + + theme = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_THEME); + maxBitrateWifi = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI); + maxBitrateMobile = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE); + maxVideoBitrateWifi = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_MAX_VIDEO_BITRATE_WIFI); + maxVideoBitrateMobile = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_MAX_VIDEO_BITRATE_MOBILE); + networkTimeout = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_NETWORK_TIMEOUT); + cacheLocation = (EditTextPreference) this.findPreference(Constants.PREFERENCES_KEY_CACHE_LOCATION); + preloadCountWifi = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_PRELOAD_COUNT_WIFI); + preloadCountMobile = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_PRELOAD_COUNT_MOBILE); + tempLoss = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_TEMP_LOSS); + pauseDisconnect = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_PAUSE_DISCONNECT); + serversCategory = (PreferenceCategory) this.findPreference(Constants.PREFERENCES_KEY_SERVER_KEY); + addServerPreference = this.findPreference(Constants.PREFERENCES_KEY_SERVER_ADD); + videoPlayer = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_VIDEO_PLAYER); + syncInterval = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_SYNC_INTERVAL); + syncEnabled = (CheckBoxPreference) this.findPreference(Constants.PREFERENCES_KEY_SYNC_ENABLED); + syncWifi = (CheckBoxPreference) this.findPreference(Constants.PREFERENCES_KEY_SYNC_WIFI); + syncNotification = (CheckBoxPreference) this.findPreference(Constants.PREFERENCES_KEY_SYNC_NOTIFICATION); + syncStarred = (CheckBoxPreference) this.findPreference(Constants.PREFERENCES_KEY_SYNC_STARRED); + syncMostRecent = (CheckBoxPreference) this.findPreference(Constants.PREFERENCES_KEY_SYNC_MOST_RECENT); + replayGain = (CheckBoxPreference) this.findPreference(Constants.PREFERENCES_KEY_REPLAY_GAIN); + replayGainType = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_REPLAY_GAIN_TYPE); + replayGainBump = this.findPreference(Constants.PREFERENCES_KEY_REPLAY_GAIN_BUMP); + replayGainUntagged = this.findPreference(Constants.PREFERENCES_KEY_REPLAY_GAIN_UNTAGGED); + cacheSize = (EditTextPreference) this.findPreference(Constants.PREFERENCES_KEY_CACHE_SIZE); + openToTab = (ListPreference) this.findPreference(Constants.PREFERENCES_KEY_OPEN_TO_TAB); + + settings = Util.getPreferences(context); + serverCount = settings.getInt(Constants.PREFERENCES_KEY_SERVER_COUNT, 1); + + this.findPreference("clearCache").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Util.confirmDialog(context, R.string.common_delete, R.string.common_confirm_message_cache, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + FileUtil.deleteMusicDirectory(context); + FileUtil.deleteSerializedCache(context); + FileUtil.deleteArtworkCache(context); + FileUtil.deleteAvatarCache(context); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, R.string.settings_cache_clear_complete); + } + + @Override + protected void error(Throwable error) { + Util.toast(context, getErrorMessage(error), false); + } + }.execute(); + } + }); + return false; + } + }); + + addServerPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + serverCount++; + String instance = String.valueOf(serverCount); + serversCategory.addPreference(addServer(serverCount)); + + SharedPreferences.Editor editor = settings.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_COUNT, serverCount); + // Reset set folder ID + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, null); + editor.commit(); + + serverSettings.put(instance, new ServerSettings(instance)); + + return true; + } + }); + + this.findPreference(Constants.PREFERENCES_KEY_SYNC_ENABLED).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + Boolean syncEnabled = (Boolean) newValue; + + Account account = new Account(Constants.SYNC_ACCOUNT_NAME, Constants.SYNC_ACCOUNT_TYPE); + ContentResolver.setSyncAutomatically(account, Constants.SYNC_ACCOUNT_PLAYLIST_AUTHORITY, syncEnabled); + ContentResolver.setSyncAutomatically(account, Constants.SYNC_ACCOUNT_PODCAST_AUTHORITY, syncEnabled); + + return true; + } + }); + syncInterval.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + Integer syncInterval = Integer.parseInt(((String) newValue)); + + Account account = new Account(Constants.SYNC_ACCOUNT_NAME, Constants.SYNC_ACCOUNT_TYPE); + ContentResolver.addPeriodicSync(account, Constants.SYNC_ACCOUNT_PLAYLIST_AUTHORITY, new Bundle(), 60L * syncInterval); + ContentResolver.addPeriodicSync(account, Constants.SYNC_ACCOUNT_PODCAST_AUTHORITY, new Bundle(), 60L * syncInterval); + + return true; + } + }); + + serversCategory.setOrderingAsAdded(false); + for (int i = 1; i <= serverCount; i++) { + String instance = String.valueOf(i); + serversCategory.addPreference(addServer(i)); + serverSettings.put(instance, new ServerSettings(instance)); + } + + SharedPreferences prefs = Util.getPreferences(context); + prefs.registerOnSharedPreferenceChangeListener(this); + + update(); + } + + private void scheduleBackup() { + try { + Class managerClass = Class.forName("android.app.backup.BackupManager"); + Constructor managerConstructor = managerClass.getConstructor(Context.class); + Object manager = managerConstructor.newInstance(context); + Method m = managerClass.getMethod("dataChanged"); + m.invoke(manager); + } catch(ClassNotFoundException e) { + Log.e(TAG, "No backup manager found"); + } catch(Throwable t) { + Log.e(TAG, "Scheduling backup failed " + t); + t.printStackTrace(); + } + } + + private void update() { + if (testingConnection) { + return; + } + + theme.setSummary(theme.getEntry()); + maxBitrateWifi.setSummary(maxBitrateWifi.getEntry()); + maxBitrateMobile.setSummary(maxBitrateMobile.getEntry()); + maxVideoBitrateWifi.setSummary(maxVideoBitrateWifi.getEntry()); + maxVideoBitrateMobile.setSummary(maxVideoBitrateMobile.getEntry()); + networkTimeout.setSummary(networkTimeout.getEntry()); + cacheLocation.setSummary(cacheLocation.getText()); + preloadCountWifi.setSummary(preloadCountWifi.getEntry()); + preloadCountMobile.setSummary(preloadCountMobile.getEntry()); + tempLoss.setSummary(tempLoss.getEntry()); + pauseDisconnect.setSummary(pauseDisconnect.getEntry()); + videoPlayer.setSummary(videoPlayer.getEntry()); + syncInterval.setSummary(syncInterval.getEntry()); + openToTab.setSummary(openToTab.getEntry()); + try { + if(megabyteFromat == null) { + megabyteFromat = new DecimalFormat(getResources().getString(R.string.util_bytes_format_megabyte)); + } + + cacheSize.setSummary(megabyteFromat.format((double) Integer.parseInt(cacheSize.getText())).replace(".00", "")); + } catch(Exception e) { + Log.e(TAG, "Failed to format cache size", e); + cacheSize.setSummary(cacheSize.getText()); + } + if(syncEnabled.isChecked()) { + if(!syncInterval.isEnabled()) { + syncInterval.setEnabled(true); + syncWifi.setEnabled(true); + syncNotification.setEnabled(true); + syncStarred.setEnabled(true); + syncMostRecent.setEnabled(true); + } + } else { + if(syncInterval.isEnabled()) { + syncInterval.setEnabled(false); + syncWifi.setEnabled(false); + syncNotification.setEnabled(false); + syncStarred.setEnabled(false); + syncMostRecent.setEnabled(false); + } + } + if(replayGain.isChecked()) { + replayGainType.setEnabled(true); + replayGainBump.setEnabled(true); + replayGainUntagged.setEnabled(true); + } else { + replayGainType.setEnabled(false); + replayGainBump.setEnabled(false); + replayGainUntagged.setEnabled(false); + } + replayGainType.setSummary(replayGainType.getEntry()); + + for (ServerSettings ss : serverSettings.values()) { + ss.update(); + } + } + + private PreferenceScreen addServer(final int instance) { + final PreferenceScreen screen = this.getPreferenceManager().createPreferenceScreen(context); + screen.setTitle(R.string.settings_server_unused); + screen.setKey(Constants.PREFERENCES_KEY_SERVER_KEY + instance); + + final EditTextPreference serverNamePreference = new EditTextPreference(context); + serverNamePreference.setKey(Constants.PREFERENCES_KEY_SERVER_NAME + instance); + serverNamePreference.setDefaultValue(getResources().getString(R.string.settings_server_unused)); + serverNamePreference.setTitle(R.string.settings_server_name); + serverNamePreference.setDialogTitle(R.string.settings_server_name); + + if (serverNamePreference.getText() == null) { + serverNamePreference.setText(getResources().getString(R.string.settings_server_unused)); + } + + serverNamePreference.setSummary(serverNamePreference.getText()); + + final EditTextPreference serverUrlPreference = new EditTextPreference(context); + serverUrlPreference.setKey(Constants.PREFERENCES_KEY_SERVER_URL + instance); + serverUrlPreference.getEditText().setInputType(InputType.TYPE_TEXT_VARIATION_URI); + serverUrlPreference.setDefaultValue("http://yourhost"); + serverUrlPreference.setTitle(R.string.settings_server_address); + serverUrlPreference.setDialogTitle(R.string.settings_server_address); + + if (serverUrlPreference.getText() == null) { + serverUrlPreference.setText("http://yourhost"); + } + + serverUrlPreference.setSummary(serverUrlPreference.getText()); + screen.setSummary(serverUrlPreference.getText()); + + final EditTextPreference serverLocalNetworkSSIDPreference = new EditTextPreference(context) { + @Override + protected void onAddEditTextToDialogView(View dialogView, final EditText editText) { + super.onAddEditTextToDialogView(dialogView, editText); + ViewGroup root = (ViewGroup) ((ViewGroup) dialogView).getChildAt(0); + + Button defaultButton = new Button(getContext()); + defaultButton.setText(internalSSIDDisplay); + defaultButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + editText.setText(internalSSID); + } + }); + root.addView(defaultButton); + } + }; + serverLocalNetworkSSIDPreference.setKey(Constants.PREFERENCES_KEY_SERVER_LOCAL_NETWORK_SSID + instance); + serverLocalNetworkSSIDPreference.setTitle(R.string.settings_server_local_network_ssid); + serverLocalNetworkSSIDPreference.setDialogTitle(R.string.settings_server_local_network_ssid); + + final EditTextPreference serverInternalUrlPreference = new EditTextPreference(context); + serverInternalUrlPreference.setKey(Constants.PREFERENCES_KEY_SERVER_INTERNAL_URL + instance); + serverInternalUrlPreference.getEditText().setInputType(InputType.TYPE_TEXT_VARIATION_URI); + serverInternalUrlPreference.setDefaultValue(""); + serverInternalUrlPreference.setTitle(R.string.settings_server_internal_address); + serverInternalUrlPreference.setDialogTitle(R.string.settings_server_internal_address); + serverInternalUrlPreference.setSummary(serverInternalUrlPreference.getText()); + + final EditTextPreference serverUsernamePreference = new EditTextPreference(context); + serverUsernamePreference.setKey(Constants.PREFERENCES_KEY_USERNAME + instance); + serverUsernamePreference.setTitle(R.string.settings_server_username); + serverUsernamePreference.setDialogTitle(R.string.settings_server_username); + + final EditTextPreference serverPasswordPreference = new EditTextPreference(context); + serverPasswordPreference.setKey(Constants.PREFERENCES_KEY_PASSWORD + instance); + serverPasswordPreference.getEditText().setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + serverPasswordPreference.setSummary("***"); + serverPasswordPreference.setTitle(R.string.settings_server_password); + + final CheckBoxPreference serverTagPreference = new CheckBoxPreference(context); + serverTagPreference.setKey(Constants.PREFERENCES_KEY_BROWSE_TAGS + instance); + serverTagPreference.setChecked(Util.isTagBrowsing(context, instance)); + serverTagPreference.setSummary(R.string.settings_browse_by_tags_summary); + serverTagPreference.setTitle(R.string.settings_browse_by_tags); + serverPasswordPreference.setDialogTitle(R.string.settings_server_password); + + final CheckBoxPreference serverSyncPreference = new CheckBoxPreference(context); + serverSyncPreference.setKey(Constants.PREFERENCES_KEY_SERVER_SYNC + instance); + serverSyncPreference.setChecked(Util.isSyncEnabled(context, instance)); + serverSyncPreference.setSummary(R.string.settings_server_sync_summary); + serverSyncPreference.setTitle(R.string.settings_server_sync); + + final Preference serverOpenBrowser = new Preference(context); + serverOpenBrowser.setKey(Constants.PREFERENCES_KEY_OPEN_BROWSER); + serverOpenBrowser.setPersistent(false); + serverOpenBrowser.setTitle(R.string.settings_server_open_browser); + serverOpenBrowser.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + openInBrowser(instance); + return true; + } + }); + + Preference serverRemoveServerPreference = new Preference(context); + serverRemoveServerPreference.setKey(Constants.PREFERENCES_KEY_SERVER_REMOVE + instance); + serverRemoveServerPreference.setPersistent(false); + serverRemoveServerPreference.setTitle(R.string.settings_servers_remove); + + serverRemoveServerPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Util.confirmDialog(context, R.string.common_delete, screen.getTitle().toString(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // Reset values to null so when we ask for them again they are new + serverNamePreference.setText(null); + serverUrlPreference.setText(null); + serverUsernamePreference.setText(null); + serverPasswordPreference.setText(null); + + int activeServer = Util.getActiveServer(context); + for (int i = instance; i <= serverCount; i++) { + Util.removeInstanceName(context, i, activeServer); + } + + serverCount--; + SharedPreferences.Editor editor = settings.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_COUNT, serverCount); + editor.commit(); + + serversCategory.removePreference(screen); + screen.getDialog().dismiss(); + } + }); + + return true; + } + }); + + Preference serverTestConnectionPreference = new Preference(context); + serverTestConnectionPreference.setKey(Constants.PREFERENCES_KEY_TEST_CONNECTION + instance); + serverTestConnectionPreference.setPersistent(false); + serverTestConnectionPreference.setTitle(R.string.settings_test_connection_title); + serverTestConnectionPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + testConnection(instance); + return false; + } + }); + + screen.addPreference(serverNamePreference); + screen.addPreference(serverUrlPreference); + screen.addPreference(serverInternalUrlPreference); + screen.addPreference(serverLocalNetworkSSIDPreference); + screen.addPreference(serverUsernamePreference); + screen.addPreference(serverPasswordPreference); + screen.addPreference(serverTagPreference); + screen.addPreference(serverSyncPreference); + screen.addPreference(serverTestConnectionPreference); + screen.addPreference(serverOpenBrowser); + screen.addPreference(serverRemoveServerPreference); + + screen.setOrder(instance); + + return screen; + } + + private void setHideMedia(boolean hide) { + File nomediaDir = new File(FileUtil.getSubsonicDirectory(context), ".nomedia"); + File musicNoMedia = new File(FileUtil.getMusicDirectory(context), ".nomedia"); + if (hide && !nomediaDir.exists()) { + try { + if (!nomediaDir.createNewFile()) { + Log.w(TAG, "Failed to create " + nomediaDir); + } + } catch(Exception e) { + Log.w(TAG, "Failed to create " + nomediaDir, e); + } + + try { + if(!musicNoMedia.createNewFile()) { + Log.w(TAG, "Failed to create " + musicNoMedia); + } + } catch(Exception e) { + Log.w(TAG, "Failed to create " + musicNoMedia, e); + } + } else if (nomediaDir.exists()) { + if (!nomediaDir.delete()) { + Log.w(TAG, "Failed to delete " + nomediaDir); + } + if(!musicNoMedia.delete()) { + Log.w(TAG, "Failed to delete " + musicNoMedia); + } + } + Util.toast(context, R.string.settings_hide_media_toast, false); + } + + private void setMediaButtonsEnabled(boolean enabled) { + if (enabled) { + Util.registerMediaButtonEventReceiver(context); + } else { + Util.unregisterMediaButtonEventReceiver(context); + } + } + + private void setCacheLocation(String path) { + File dir = new File(path); + if (!FileUtil.verifyCanWrite(dir)) { + Util.toast(context, R.string.settings_cache_location_error, false); + + // Reset it to the default. + String defaultPath = FileUtil.getDefaultMusicDirectory(context).getPath(); + if (!defaultPath.equals(path)) { + SharedPreferences prefs = Util.getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_CACHE_LOCATION, defaultPath); + editor.commit(); + cacheLocation.setSummary(defaultPath); + cacheLocation.setText(defaultPath); + } + + // Clear download queue. + DownloadService downloadService = DownloadService.getInstance(); + downloadService.clear(); + } + } + + private void testConnection(final int instance) { + LoadingTask task = new LoadingTask(context) { + private int previousInstance; + + @Override + protected Boolean doInBackground() throws Throwable { + updateProgress(R.string.settings_testing_connection); + + previousInstance = Util.getActiveServer(context); + testingConnection = true; + MusicService musicService = MusicServiceFactory.getMusicService(context); + try { + musicService.setInstance(instance); + musicService.ping(context, this); + return musicService.isLicenseValid(context, null); + } finally { + musicService.setInstance(null); + testingConnection = false; + } + } + + @Override + protected void done(Boolean licenseValid) { + if (licenseValid) { + Util.toast(context, R.string.settings_testing_ok); + } else { + Util.toast(context, R.string.settings_testing_unlicensed); + } + } + + @Override + public void cancel() { + super.cancel(); + Util.setActiveServer(context, previousInstance); + } + + @Override + protected void error(Throwable error) { + Log.w(TAG, error.toString(), error); + new ErrorDialog(context, getResources().getString(R.string.settings_connection_failure) + + " " + getErrorMessage(error), false); + } + }; + task.execute(); + } + + private void openInBrowser(final int instance) { + SharedPreferences prefs = Util.getPreferences(context); + String url = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + if(url == null) { + new ErrorDialog(context, R.string.settings_invalid_url, false); + return; + } + Uri uriServer = Uri.parse(url); + + Intent browserIntent = new Intent(Intent.ACTION_VIEW, uriServer); + startActivity(browserIntent); + } + + private class ServerSettings { + private EditTextPreference serverName; + private EditTextPreference serverUrl; + private EditTextPreference serverLocalNetworkSSID; + private EditTextPreference serverInternalUrl; + private EditTextPreference username; + private PreferenceScreen screen; + + private ServerSettings(String instance) { + screen = (PreferenceScreen) SettingsFragment.this.findPreference("server" + instance); + serverName = (EditTextPreference) SettingsFragment.this.findPreference(Constants.PREFERENCES_KEY_SERVER_NAME + instance); + serverUrl = (EditTextPreference) SettingsFragment.this.findPreference(Constants.PREFERENCES_KEY_SERVER_URL + instance); + serverLocalNetworkSSID = (EditTextPreference) SettingsFragment.this.findPreference(Constants.PREFERENCES_KEY_SERVER_LOCAL_NETWORK_SSID + instance); + serverInternalUrl = (EditTextPreference) SettingsFragment.this.findPreference(Constants.PREFERENCES_KEY_SERVER_INTERNAL_URL + instance); + username = (EditTextPreference) SettingsFragment.this.findPreference(Constants.PREFERENCES_KEY_USERNAME + instance); + + serverUrl.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + try { + String url = (String) value; + new URL(url); + if (url.contains(" ") || url.contains("@") || url.contains("_")) { + throw new Exception(); + } + } catch (Exception x) { + new ErrorDialog(context, R.string.settings_invalid_url, false); + return false; + } + return true; + } + }); + serverInternalUrl.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + try { + String url = (String) value; + // Allow blank internal IP address + if("".equals(url) || url == null) { + return true; + } + + new URL(url); + if (url.contains(" ") || url.contains("@") || url.contains("_")) { + throw new Exception(); + } + } catch (Exception x) { + new ErrorDialog(context, R.string.settings_invalid_url, false); + return false; + } + return true; + } + }); + + username.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + String username = (String) value; + if (username == null || !username.equals(username.trim())) { + new ErrorDialog(context, R.string.settings_invalid_username, false); + return false; + } + return true; + } + }); + } + + public void update() { + serverName.setSummary(serverName.getText()); + serverUrl.setSummary(serverUrl.getText()); + serverLocalNetworkSSID.setSummary(serverLocalNetworkSSID.getText()); + serverInternalUrl.setSummary(serverInternalUrl.getText()); + username.setSummary(username.getText()); + screen.setSummary(serverUrl.getText()); + screen.setTitle(serverName.getText()); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SimilarArtistFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SimilarArtistFragment.java new file mode 100644 index 00000000..79e759cc --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SimilarArtistFragment.java @@ -0,0 +1,169 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.adapter.ArtistAdapter; + +import java.net.URLEncoder; +import java.util.LinkedList; +import java.util.List; + +public class SimilarArtistFragment extends SelectListFragment { + private static final String TAG = SimilarArtistFragment.class.getSimpleName(); + private ArtistInfo info; + private String artistId; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + artist = true; + + artistId = getArguments().getString(Constants.INTENT_EXTRA_NAME_ARTIST); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_play_now: + playAll(false); + return true; + case R.id.menu_shuffle: + playAll(true); + return true; + case R.id.menu_show_missing: + showMissingArtists(); + break; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; + Object entry = listView.getItemAtPosition(info.position); + onCreateContextMenu(menu, view, menuInfo, entry); + + recreateContextMenu(menu); + } + + @Override + public boolean onContextItemSelected(MenuItem menuItem) { + if(menuItem.getGroupId() != getSupportTag()) { + return false; + } + + AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo(); + Artist artist = (Artist) listView.getItemAtPosition(info.position); + return onContextItemSelected(menuItem, artist); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Artist artist = (Artist) parent.getItemAtPosition(position); + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + args.putString(Constants.INTENT_EXTRA_NAME_ID, artist.getId()); + args.putString(Constants.INTENT_EXTRA_NAME_NAME, artist.getName()); + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + fragment.setArguments(args); + + replaceFragment(fragment); + } + + @Override + public int getOptionsMenu() { + return R.menu.similar_artists; + } + + @Override + public ArrayAdapter getAdapter(List objects) { + return new ArtistAdapter(context, objects); + } + + @Override + public List getObjects(MusicService musicService, boolean refresh, ProgressListener listener) throws Exception { + info = musicService.getArtistInfo(artistId, refresh, true, context, listener); + return info.getSimilarArtists(); + } + + @Override + public int getTitleResource() { + return R.string.menu_similar_artists; + } + + private void showMissingArtists() { + StringBuilder b = new StringBuilder(); + + for(String name: info.getMissingArtists()) { + b.append("

" + name + "

"); + } + + Util.showHTMLDialog(context, R.string.menu_similar_artists, b.toString()); + } + + private void playAll(final boolean shuffle) { + new RecursiveLoader(context) { + @Override + protected Boolean doInBackground() throws Throwable { + musicService = MusicServiceFactory.getMusicService(context); + + MusicDirectory root = new MusicDirectory(); + for(Artist artist: objects) { + if(Util.isTagBrowsing(context) && !Util.isOffline(context)) { + root.addChildren(musicService.getArtist(artist.getId(), artist.getName(), false, context, this).getChildren()); + } else { + root.addChildren(musicService.getMusicDirectory(artist.getId(), artist.getName(), false, context, this).getChildren()); + } + } + + if(shuffle) { + root.shuffleChildren(); + } + + songs = new LinkedList(); + getSongsRecursively(root, songs); + + DownloadService downloadService = getDownloadService(); + if (!songs.isEmpty() && downloadService != null) { + downloadService.clear(); + downloadService.download(songs, false, true, false, false); + } + + return true; + } + }.execute(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/SubsonicFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/SubsonicFragment.java new file mode 100644 index 00000000..109983ba --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/SubsonicFragment.java @@ -0,0 +1,1817 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.fragments; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Bundle; +import android.os.StatFs; +import android.support.v4.app.Fragment; +import android.support.v4.widget.SwipeRefreshLayout; +import android.util.Log; +import android.view.ContextMenu; +import android.view.GestureDetector; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.RatingBar; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.DownloadActivity; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MediaStoreService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.LoadingTask; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.view.AlbumCell; +import github.daneren2005.dsub.view.AlbumView; +import github.daneren2005.dsub.view.ArtistEntryView; +import github.daneren2005.dsub.view.ArtistView; +import github.daneren2005.dsub.view.PlaylistSongView; +import github.daneren2005.dsub.view.SongView; +import github.daneren2005.dsub.view.UpdateView; + +import java.io.File; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Random; + +import static github.daneren2005.dsub.domain.MusicDirectory.Entry; + +public class SubsonicFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { + private static final String TAG = SubsonicFragment.class.getSimpleName(); + private static int TAG_INC = 10; + private int tag; + + protected SubsonicActivity context; + protected CharSequence title = null; + protected CharSequence subtitle = null; + protected View rootView; + protected boolean primaryFragment = false; + protected boolean secondaryFragment = false; + protected boolean invalidated = false; + protected static Random random = new Random(); + protected GestureDetector gestureScanner; + protected Share share; + protected boolean artist = false; + protected boolean artistOverride = false; + protected SwipeRefreshLayout refreshLayout; + protected boolean firstRun; + + public SubsonicFragment() { + super(); + tag = TAG_INC++; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + if(bundle != null) { + String name = bundle.getString(Constants.FRAGMENT_NAME); + if(name != null) { + title = name; + } + } + firstRun = true; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if(title != null) { + outState.putString(Constants.FRAGMENT_NAME, title.toString()); + } + } + + @Override + public void onResume() { + super.onResume(); + if(firstRun) { + firstRun = false; + } else { + UpdateView.triggerUpdate(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + context = (SubsonicActivity)activity; + } + + public void setContext(SubsonicActivity context) { + this.context = context; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_shuffle: + onShuffleRequested(); + return true; + case R.id.menu_search: + context.onSearchRequested(); + return true; + case R.id.menu_exit: + exit(); + return true; + case R.id.menu_refresh: + refresh(); + return true; + } + + return false; + } + + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo, Object selected) { + MenuInflater inflater = context.getMenuInflater(); + + if(selected instanceof Entry) { + Entry entry = (Entry) selected; + if(entry instanceof PodcastEpisode && !entry.isVideo()) { + if(Util.isOffline(context)) { + inflater.inflate(R.menu.select_podcast_episode_context_offline, menu); + } + else { + inflater.inflate(R.menu.select_podcast_episode_context, menu); + + if(entry.getBookmark() == null) { + menu.removeItem(R.id.bookmark_menu_delete); + } + } + } + else if (entry.isDirectory()) { + if(Util.isOffline(context)) { + inflater.inflate(R.menu.select_album_context_offline, menu); + } + else { + inflater.inflate(R.menu.select_album_context, menu); + + if(Util.isTagBrowsing(context)) { + menu.removeItem(R.id.menu_rate); + } + } + menu.findItem(entry.isDirectory() ? R.id.album_menu_star : R.id.song_menu_star).setTitle(entry.isStarred() ? R.string.common_unstar : R.string.common_star); + } else if(!entry.isVideo()) { + if(Util.isOffline(context)) { + inflater.inflate(R.menu.select_song_context_offline, menu); + } + else { + inflater.inflate(R.menu.select_song_context, menu); + + if(entry.getBookmark() == null) { + menu.removeItem(R.id.bookmark_menu_delete); + } + } + menu.findItem(entry.isDirectory() ? R.id.album_menu_star : R.id.song_menu_star).setTitle(entry.isStarred() ? R.string.common_unstar : R.string.common_star); + } else { + if(Util.isOffline(context)) { + inflater.inflate(R.menu.select_video_context_offline, menu); + } + else { + inflater.inflate(R.menu.select_video_context, menu); + } + } + } else if(selected instanceof Artist) { + Artist artist = (Artist) selected; + if(Util.isOffline(context)) { + inflater.inflate(R.menu.select_artist_context_offline, menu); + } + else { + inflater.inflate(R.menu.select_artist_context, menu); + + menu.findItem(R.id.artist_menu_star).setTitle(artist.isStarred() ? R.string.common_unstar : R.string.common_star); + } + } + + hideMenuItems(menu, (AdapterView.AdapterContextMenuInfo) menuInfo); + } + + protected void hideMenuItems(ContextMenu menu, AdapterView.AdapterContextMenuInfo info) { + if(!ServerInfo.checkServerVersion(context, "1.8")) { + menu.setGroupVisible(R.id.server_1_8, false); + menu.setGroupVisible(R.id.hide_star, false); + } + if(!ServerInfo.checkServerVersion(context, "1.9")) { + menu.setGroupVisible(R.id.server_1_9, false); + } + if(!ServerInfo.checkServerVersion(context, "1.10.1")) { + menu.setGroupVisible(R.id.server_1_10, false); + } + + SharedPreferences prefs = Util.getPreferences(context); + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_PLAY_NEXT, true)) { + menu.setGroupVisible(R.id.hide_play_next, false); + } + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_PLAY_LAST, true)) { + menu.setGroupVisible(R.id.hide_play_last, false); + } + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_STAR, true)) { + menu.setGroupVisible(R.id.hide_star, false); + } + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_SHARED, true) || !UserUtil.canShare()) { + menu.setGroupVisible(R.id.hide_share, false); + } + if(!prefs.getBoolean(Constants.PREFERENCES_KEY_MENU_RATING, true)) { + menu.setGroupVisible(R.id.hide_rating, false); + } + + if(!Util.isOffline(context)) { + // If we are looking at a standard song view, get downloadFile to cache what options to show + if(info.targetView instanceof SongView) { + SongView songView = (SongView) info.targetView; + DownloadFile downloadFile = songView.getDownloadFile(); + + try { + if(downloadFile != null) { + if(downloadFile.isWorkDone()) { + // Remove permanent cache menu if already perma cached + if(downloadFile.isSaved()) { + menu.removeItem(R.id.song_menu_pin); + } + + // Remove cache option no matter what if already downloaded + menu.removeItem(R.id.song_menu_download); + } else { + // Remove delete option if nothing to delete + menu.removeItem(R.id.song_menu_delete); + } + } + } catch(Exception e) { + Log.w(TAG, "Failed to lookup downloadFile info", e); + } + } + // Apply similar logic to album views + else if(info.targetView instanceof AlbumCell || info.targetView instanceof AlbumView + || info.targetView instanceof ArtistView || info.targetView instanceof ArtistEntryView) { + File folder = null; + int id = 0; + if(info.targetView instanceof AlbumCell) { + folder = ((AlbumCell) info.targetView).getFile(); + id = R.id.album_menu_delete; + } else if(info.targetView instanceof AlbumView) { + folder = ((AlbumView) info.targetView).getFile(); + id = R.id.album_menu_delete; + } else if(info.targetView instanceof ArtistView) { + folder = ((ArtistView) info.targetView).getFile(); + id = R.id.artist_menu_delete; + } else if(info.targetView instanceof ArtistEntryView) { + folder = ((ArtistEntryView) info.targetView).getFile(); + id = R.id.artist_menu_delete; + } + + try { + if(folder != null && !folder.exists()) { + menu.removeItem(id); + } + } catch(Exception e) { + Log.w(TAG, "Failed to lookup album directory info", e); + } + } + } + } + + protected void recreateContextMenu(ContextMenu menu) { + List menuItems = new ArrayList(); + for(int i = 0; i < menu.size(); i++) { + MenuItem item = menu.getItem(i); + if(item.isVisible()) { + menuItems.add(item); + } + } + menu.clear(); + for(int i = 0; i < menuItems.size(); i++) { + MenuItem item = menuItems.get(i); + menu.add(tag, item.getItemId(), Menu.NONE, item.getTitle()); + } + } + + public boolean onContextItemSelected(MenuItem menuItem, Object selectedItem) { + Artist artist = selectedItem instanceof Artist ? (Artist) selectedItem : null; + Entry entry = selectedItem instanceof Entry ? (Entry) selectedItem : null; + List songs = new ArrayList(1); + songs.add(entry); + + switch (menuItem.getItemId()) { + case R.id.artist_menu_play_now: + downloadRecursively(artist.getId(), false, false, true, false, false); + break; + case R.id.artist_menu_play_shuffled: + downloadRecursively(artist.getId(), false, false, true, true, false); + break; + case R.id.artist_menu_play_next: + downloadRecursively(artist.getId(), false, true, false, false, false, true); + break; + case R.id.artist_menu_play_last: + downloadRecursively(artist.getId(), false, true, false, false, false); + break; + case R.id.artist_menu_download: + downloadRecursively(artist.getId(), false, true, false, false, true); + break; + case R.id.artist_menu_pin: + downloadRecursively(artist.getId(), true, true, false, false, true); + break; + case R.id.artist_menu_delete: + deleteRecursively(artist); + break; + case R.id.artist_menu_star: + toggleStarred(artist); + break; + case R.id.album_menu_play_now: + artistOverride = true; + downloadRecursively(entry.getId(), false, false, true, false, false); + break; + case R.id.album_menu_play_shuffled: + artistOverride = true; + downloadRecursively(entry.getId(), false, false, true, true, false); + break; + case R.id.album_menu_play_next: + artistOverride = true; + downloadRecursively(entry.getId(), false, true, false, false, false, true); + break; + case R.id.album_menu_play_last: + artistOverride = true; + downloadRecursively(entry.getId(), false, true, false, false, false); + break; + case R.id.album_menu_download: + artistOverride = true; + downloadRecursively(entry.getId(), false, true, false, false, true); + break; + case R.id.album_menu_pin: + artistOverride = true; + downloadRecursively(entry.getId(), true, true, false, false, true); + break; + case R.id.album_menu_star: + toggleStarred(entry); + break; + case R.id.album_menu_delete: + deleteRecursively(entry); + break; + case R.id.album_menu_info: + displaySongInfo(entry); + break; + case R.id.album_menu_show_artist: + showAlbumArtist((Entry) selectedItem); + break; + case R.id.album_menu_share: + createShare(songs); + break; + case R.id.song_menu_play_now: + playNow(songs); + break; + case R.id.song_menu_play_next: + getDownloadService().download(songs, false, false, true, false); + break; + case R.id.song_menu_play_last: + getDownloadService().download(songs, false, false, false, false); + break; + case R.id.song_menu_download: + getDownloadService().downloadBackground(songs, false); + break; + case R.id.song_menu_pin: + getDownloadService().downloadBackground(songs, true); + break; + case R.id.song_menu_delete: + getDownloadService().delete(songs); + break; + case R.id.song_menu_add_playlist: + addToPlaylist(songs); + break; + case R.id.song_menu_star: + toggleStarred(entry); + break; + case R.id.song_menu_play_external: + playExternalPlayer(entry); + break; + case R.id.song_menu_info: + displaySongInfo(entry); + break; + case R.id.song_menu_stream_external: + streamExternalPlayer(entry); + break; + case R.id.song_menu_share: + createShare(songs); + break; + case R.id.song_menu_show_album: + showAlbum((Entry) selectedItem); + break; + case R.id.song_menu_show_artist: + showArtist((Entry) selectedItem); + break; + case R.id.bookmark_menu_delete: + deleteBookmark(entry, null); + break; + case R.id.menu_rate: + setRating(entry); + break; + default: + return false; + } + + return true; + } + + public void replaceFragment(SubsonicFragment fragment) { + replaceFragment(fragment, true); + } + public void replaceFragment(SubsonicFragment fragment, boolean replaceCurrent) { + context.replaceFragment(fragment, fragment.getSupportTag(), secondaryFragment && replaceCurrent); + } + + public int getRootId() { + return rootView.getId(); + } + + public void setSupportTag(int tag) { this.tag = tag; } + public void setSupportTag(String tag) { this.tag = Integer.parseInt(tag); } + public int getSupportTag() { + return tag; + } + + public void setPrimaryFragment(boolean primary) { + primaryFragment = primary; + if(primary) { + if(context != null && title != null) { + context.setTitle(title); + context.setSubtitle(subtitle); + } + if(invalidated) { + invalidated = false; + refresh(false); + } + } + } + public void setPrimaryFragment(boolean primary, boolean secondary) { + setPrimaryFragment(primary); + secondaryFragment = secondary; + } + public void setSecondaryFragment(boolean secondary) { + secondaryFragment = secondary; + } + + public void invalidate() { + if(primaryFragment) { + refresh(true); + } else { + invalidated = true; + } + } + + public DownloadService getDownloadService() { + return context != null ? context.getDownloadService() : null; + } + + protected void refresh() { + refresh(true); + } + protected void refresh(boolean refresh) { + + } + + @Override + public void onRefresh() { + refreshLayout.setRefreshing(false); + refresh(); + } + + protected void exit() { + if(((Object) context).getClass() != SubsonicFragmentActivity.class) { + Intent intent = new Intent(context, SubsonicFragmentActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(Constants.INTENT_EXTRA_NAME_EXIT, true); + Util.startActivityWithoutTransition(context, intent); + } else { + context.stopService(new Intent(context, DownloadService.class)); + context.finish(); + } + } + + public void setProgressVisible(boolean visible) { + View view = rootView.findViewById(R.id.tab_progress); + if (view != null) { + view.setVisibility(visible ? View.VISIBLE : View.GONE); + + if(visible) { + View progress = rootView.findViewById(R.id.tab_progress_spinner); + progress.setVisibility(View.VISIBLE); + } + } + } + + public void updateProgress(String message) { + TextView view = (TextView) rootView.findViewById(R.id.tab_progress_message); + if (view != null) { + view.setText(message); + } + } + + public void setEmpty(boolean empty) { + View view = rootView.findViewById(R.id.tab_progress); + if(empty) { + view.setVisibility(View.VISIBLE); + + View progress = view.findViewById(R.id.tab_progress_spinner); + progress.setVisibility(View.GONE); + + TextView text = (TextView) view.findViewById(R.id.tab_progress_message); + text.setText(R.string.common_empty); + } else { + view.setVisibility(View.GONE); + } + } + + protected synchronized ImageLoader getImageLoader() { + return context.getImageLoader(); + } + public synchronized static ImageLoader getStaticImageLoader(Context context) { + return SubsonicActivity.getStaticImageLoader(context); + } + + public void setTitle(CharSequence title) { + this.title = title; + context.setTitle(title); + } + public void setTitle(int title) { + this.title = context.getResources().getString(title); + context.setTitle(this.title); + } + public void setSubtitle(CharSequence title) { + this.subtitle = title; + context.setSubtitle(title); + } + public CharSequence getTitle() { + return this.title; + } + + protected void setupScrollList(final AbsListView listView) { + if(!context.isTouchscreen()) { + refreshLayout.setEnabled(false); + } else { + listView.setOnScrollListener(new AbsListView.OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + int topRowVerticalPosition = (listView.getChildCount() == 0) ? 0 : listView.getChildAt(0).getTop(); + refreshLayout.setEnabled(topRowVerticalPosition >= 0 && listView.getFirstVisiblePosition() == 0); + } + }); + + refreshLayout.setColorScheme( + R.color.holo_blue_light, + R.color.holo_orange_light, + R.color.holo_green_light, + R.color.holo_red_light); + } + } + + protected void warnIfStorageUnavailable() { + if (!Util.isExternalStoragePresent()) { + Util.toast(context, R.string.select_album_no_sdcard); + } + + try { + StatFs stat = new StatFs(FileUtil.getMusicDirectory(context).getPath()); + long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); + if (bytesAvailableFs < 50000000L) { + Util.toast(context, context.getResources().getString(R.string.select_album_no_room, Util.formatBytes(bytesAvailableFs))); + } + } catch(Exception e) { + Log.w(TAG, "Error while checking storage space for music directory", e); + } + } + + protected void onShuffleRequested() { + if(Util.isOffline(context)) { + Intent intent = new Intent(context, DownloadActivity.class); + intent.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, true); + Util.startActivityWithoutTransition(context, intent); + return; + } + + View dialogView = context.getLayoutInflater().inflate(R.layout.shuffle_dialog, null); + final EditText startYearBox = (EditText)dialogView.findViewById(R.id.start_year); + final EditText endYearBox = (EditText)dialogView.findViewById(R.id.end_year); + final EditText genreBox = (EditText)dialogView.findViewById(R.id.genre); + final Button genreCombo = (Button)dialogView.findViewById(R.id.genre_combo); + + final SharedPreferences prefs = Util.getPreferences(context); + final String oldStartYear = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, ""); + final String oldEndYear = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, ""); + final String oldGenre = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, ""); + + boolean _useCombo = false; + if(ServerInfo.checkServerVersion(context, "1.9.0")) { + genreBox.setVisibility(View.GONE); + genreCombo.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + new LoadingTask>(context, true) { + @Override + protected List doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.getGenres(false, context, this); + } + + @Override + protected void done(final List genres) { + List names = new ArrayList(); + String blank = context.getResources().getString(R.string.select_genre_blank); + names.add(blank); + for(Genre genre: genres) { + names.add(genre.getName()); + } + final List finalNames = names; + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.shuffle_pick_genre) + .setItems(names.toArray(new CharSequence[names.size()]), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if(which == 0) { + genreCombo.setText(""); + } else { + genreCombo.setText(finalNames.get(which)); + } + } + }); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.playlist_error) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + }); + _useCombo = true; + } else { + genreCombo.setVisibility(View.GONE); + } + final boolean useCombo = _useCombo; + + startYearBox.setText(oldStartYear); + endYearBox.setText(oldEndYear); + genreBox.setText(oldGenre); + genreCombo.setText(oldGenre); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.shuffle_title) + .setView(dialogView) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + Intent intent = new Intent(context, DownloadActivity.class); + intent.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, true); + String genre; + if(useCombo) { + genre = genreCombo.getText().toString(); + } else { + genre = genreBox.getText().toString(); + } + String startYear = startYearBox.getText().toString(); + String endYear = endYearBox.getText().toString(); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, startYear); + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, endYear); + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, genre); + editor.commit(); + + Util.startActivityWithoutTransition(context, intent); + } + }) + .setNegativeButton(R.string.common_cancel, null); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + public void toggleStarred(Entry entry) { + toggleStarred(entry, null); + } + public void toggleStarred(final Entry entry, final OnStarChange onStarChange) { + final boolean starred = !entry.isStarred(); + entry.setStarred(starred); + if(onStarChange != null) { + onStarChange.starChange(starred); + } + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + if(entry.isDirectory() && Util.isTagBrowsing(context) && !Util.isOffline(context)) { + if(entry.isAlbum()) { + musicService.setStarred(null, null, Arrays.asList(entry), starred, null, context); + } else { + musicService.setStarred(null, Arrays.asList(entry), null, starred, null, context); + } + } else { + musicService.setStarred(Arrays.asList(entry), null, null, starred, null, context); + } + + new EntryInstanceUpdater(entry) { + @Override + public void update(Entry found) { + found.setStarred(starred); + } + }.execute(); + + return null; + } + + @Override + protected void done(Void result) { + // UpdateView + Util.toast(context, context.getResources().getString(starred ? R.string.starring_content_starred : R.string.starring_content_unstarred, entry.getTitle())); + } + + @Override + protected void error(Throwable error) { + Log.w(TAG, "Failed to star", error); + entry.setStarred(!starred); + if(onStarChange != null) { + onStarChange.starChange(!starred); + } + + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.starring_content_error, entry.getTitle()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + public void toggleStarred(final Artist entry) { + final boolean starred = !entry.isStarred(); + entry.setStarred(starred); + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + if(Util.isTagBrowsing(context) && !Util.isOffline(context)) { + musicService.setStarred(null, Arrays.asList(new Entry(entry)), null, starred, null, context); + } else { + musicService.setStarred(Arrays.asList(new Entry(entry)), null, null, starred, null, context); + } + return null; + } + + @Override + protected void done(Void result) { + // UpdateView + Util.toast(context, context.getResources().getString(starred ? R.string.starring_content_starred : R.string.starring_content_unstarred, entry.getName())); + } + + @Override + protected void error(Throwable error) { + Log.w(TAG, "Failed to star", error); + entry.setStarred(!starred); + + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.starring_content_error, entry.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + protected void downloadRecursively(final String id, final boolean save, final boolean append, final boolean autoplay, final boolean shuffle, final boolean background) { + downloadRecursively(id, "", true, save, append, autoplay, shuffle, background); + } + protected void downloadRecursively(final String id, final boolean save, final boolean append, final boolean autoplay, final boolean shuffle, final boolean background, final boolean playNext) { + downloadRecursively(id, "", true, save, append, autoplay, shuffle, background, playNext); + } + protected void downloadPlaylist(final String id, final String name, final boolean save, final boolean append, final boolean autoplay, final boolean shuffle, final boolean background) { + downloadRecursively(id, name, false, save, append, autoplay, shuffle, background); + } + protected void downloadRecursively(final String id, final String name, final boolean isDirectory, final boolean save, final boolean append, final boolean autoplay, final boolean shuffle, final boolean background) { + downloadRecursively(id, name, isDirectory, save, append, autoplay, shuffle, background, false); + } + protected void downloadRecursively(final String id, final String name, final boolean isDirectory, final boolean save, final boolean append, final boolean autoplay, final boolean shuffle, final boolean background, final boolean playNext) { + new RecursiveLoader(context) { + @Override + protected Boolean doInBackground() throws Throwable { + musicService = MusicServiceFactory.getMusicService(context); + MusicDirectory root; + if(share != null) { + root = share.getMusicDirectory(); + } + else if(isDirectory) { + if(id != null) { + root = getMusicDirectory(id, name, false, musicService, this); + } else { + root = musicService.getStarredList(context, this); + } + } + else { + root = musicService.getPlaylist(true, id, name, context, this); + } + + if(shuffle) { + Collections.shuffle(root.getChildren()); + } + + songs = new LinkedList(); + getSongsRecursively(root, songs); + + DownloadService downloadService = getDownloadService(); + boolean transition = false; + if (!songs.isEmpty() && downloadService != null) { + // Conditions for a standard play now operation + if(!append && !save && autoplay && !playNext && !shuffle && !background) { + playNowOverride = true; + return false; + } + + if (!append && !background) { + downloadService.clear(); + } + if(!background) { + downloadService.download(songs, save, autoplay, playNext, false); + if(!append) { + transition = true; + } + } + else { + downloadService.downloadBackground(songs, save); + } + } + artistOverride = false; + + return transition; + } + }.execute(); + } + + protected void downloadRecursively(final List albums, final boolean shuffle, final boolean append) { + new RecursiveLoader(context) { + @Override + protected Boolean doInBackground() throws Throwable { + musicService = MusicServiceFactory.getMusicService(context); + + if(shuffle) { + Collections.shuffle(albums); + } + + songs = new LinkedList(); + MusicDirectory root = new MusicDirectory(); + root.addChildren(albums); + getSongsRecursively(root, songs); + + DownloadService downloadService = getDownloadService(); + boolean transition = false; + if (!songs.isEmpty() && downloadService != null) { + // Conditions for a standard play now operation + if(!append && !shuffle) { + playNowOverride = true; + return false; + } + + if (!append) { + downloadService.clear(); + } + + downloadService.download(songs, false, true, false, false); + if(!append) { + transition = true; + } + } + artistOverride = false; + + return transition; + } + }.execute(); + } + + protected MusicDirectory getMusicDirectory(String id, String name, boolean refresh, MusicService service, ProgressListener listener) throws Exception { + return getMusicDirectory(id, name, refresh, false, service, listener); + } + protected MusicDirectory getMusicDirectory(String id, String name, boolean refresh, boolean forceArtist, MusicService service, ProgressListener listener) throws Exception { + if(Util.isTagBrowsing(context) && !Util.isOffline(context)) { + if(artist && !artistOverride || forceArtist) { + return service.getArtist(id, name, refresh, context, listener); + } else { + return service.getAlbum(id, name, refresh, context, listener); + } + } else { + return service.getMusicDirectory(id, name, refresh, context, listener); + } + } + + protected void addToPlaylist(final List songs) { + if(songs.isEmpty()) { + Util.toast(context, "No songs selected"); + return; + } + + new LoadingTask>(context, true) { + @Override + protected List doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + List playlists = new ArrayList(); + playlists.addAll(musicService.getPlaylists(false, context, this)); + + // Iterate through and remove all non owned public playlists + Iterator it = playlists.iterator(); + while(it.hasNext()) { + Playlist playlist = it.next(); + if(playlist.getPublic() == true && playlist.getId().indexOf(".m3u") == -1 && !UserUtil.getCurrentUsername(context).equals(playlist.getOwner())) { + it.remove(); + } + } + + return playlists; + } + + @Override + protected void done(final List playlists) { + // Create adapter to show playlists + Playlist createNew = new Playlist("-1", context.getResources().getString(R.string.playlist_create_new)); + playlists.add(0, createNew); + ArrayAdapter playlistAdapter = new ArrayAdapter(context, R.layout.basic_count_item, playlists) { + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Playlist playlist = getItem(position); + + // Create new if not getting a convert view to use + PlaylistSongView view; + if(convertView instanceof PlaylistSongView) { + view = (PlaylistSongView) convertView; + } else { + view = new PlaylistSongView(context); + } + + view.setObject(playlist, songs); + + return view; + } + }; + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.playlist_add_to) + .setAdapter(playlistAdapter, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if (which > 0) { + addToPlaylist(playlists.get(which), songs); + } else { + createNewPlaylist(songs, false); + } + } + }); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.playlist_error) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + private void addToPlaylist(final Playlist playlist, final List songs) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.addToPlaylist(playlist.getId(), songs, context, null); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getString(R.string.updated_playlist, songs.size(), playlist.getName())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.updated_playlist_error, playlist.getName()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + protected void createNewPlaylist(final List songs, final boolean getSuggestion) { + View layout = context.getLayoutInflater().inflate(R.layout.save_playlist, null); + final EditText playlistNameView = (EditText) layout.findViewById(R.id.save_playlist_name); + final CheckBox overwriteCheckBox = (CheckBox) layout.findViewById(R.id.save_playlist_overwrite); + if(getSuggestion) { + String playlistName = (getDownloadService() != null) ? getDownloadService().getSuggestedPlaylistName() : null; + if (playlistName != null) { + playlistNameView.setText(playlistName); + try { + if(ServerInfo.checkServerVersion(context, "1.8.0") && Integer.parseInt(getDownloadService().getSuggestedPlaylistId()) != -1) { + overwriteCheckBox.setChecked(true); + overwriteCheckBox.setVisibility(View.VISIBLE); + } + } catch(Exception e) { + Log.d(TAG, "Playlist id isn't a integer, probably MusicCabinet"); + } + } else { + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + playlistNameView.setText(dateFormat.format(new Date())); + } + } else { + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + playlistNameView.setText(dateFormat.format(new Date())); + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.download_playlist_title) + .setMessage(R.string.download_playlist_name) + .setView(layout) + .setPositiveButton(R.string.common_save, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + String playlistName = String.valueOf(playlistNameView.getText()); + if(overwriteCheckBox.isChecked()) { + overwritePlaylist(songs, playlistName, getDownloadService().getSuggestedPlaylistId()); + } else { + createNewPlaylist(songs, playlistName); + + if(getSuggestion) { + DownloadService downloadService = getDownloadService(); + if(downloadService != null) { + downloadService.setSuggestedPlaylistName(playlistName, null); + } + } + } + } + }) + .setNegativeButton(R.string.common_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }) + .setCancelable(true); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + private void createNewPlaylist(final List songs, final String name) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.createPlaylist(null, name, songs, context, null); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, R.string.download_playlist_done); + } + + @Override + protected void error(Throwable error) { + String msg = context.getResources().getString(R.string.download_playlist_error) + " " + getErrorMessage(error); + Util.toast(context, msg); + } + }.execute(); + } + private void overwritePlaylist(final List songs, final String name, final String id) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + MusicDirectory playlist = musicService.getPlaylist(true, id, name, context, null); + List toDelete = playlist.getChildren(); + musicService.overwritePlaylist(id, name, toDelete.size(), songs, context, null); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, R.string.download_playlist_done); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.download_playlist_error) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + public void displaySongInfo(final Entry song) { + Integer duration = null; + Integer bitrate = null; + String format = null; + long size = 0; + if(!song.isDirectory()) { + try { + DownloadFile downloadFile = new DownloadFile(context, song, false); + File file = downloadFile.getCompleteFile(); + if(file.exists()) { + MediaMetadataRetriever metadata = new MediaMetadataRetriever(); + metadata.setDataSource(file.getAbsolutePath()); + + String tmp = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + duration = Integer.parseInt((tmp != null) ? tmp : "0") / 1000; + format = FileUtil.getExtension(file.getName()); + size = file.length(); + + // If no duration try to read bitrate tag + if(duration == null) { + tmp = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE); + bitrate = Integer.parseInt((tmp != null) ? tmp : "0") / 1000; + } else { + // Otherwise do a calculation for it + // Divide by 1000 so in kbps + bitrate = (int) (size / duration) / 1000 * 8; + } + + if(Util.isOffline(context)) { + song.setGenre(metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE)); + String year = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR); + song.setYear(Integer.parseInt((year != null) ? year : "0")); + } + } + } catch(Exception e) { + Log.i(TAG, "Device doesn't properly support MediaMetadataRetreiver"); + } + } + + String msg = ""; + if(song instanceof PodcastEpisode) { + msg += "Podcast: " + song.getArtist() + "\nStatus: " + ((PodcastEpisode)song).getStatus(); + } else if(!song.isVideo()) { + if(song.getArtist() != null && !"".equals(song.getArtist())) { + msg += "Artist: " + song.getArtist(); + } + if(song.getAlbum() != null && !"".equals(song.getAlbum())) { + msg += "\nAlbum: " + song.getAlbum(); + } + } + if(song.getTrack() != null && song.getTrack() != 0) { + msg += "\nTrack: " + song.getTrack(); + } + if(song.getGenre() != null && !"".equals(song.getGenre())) { + msg += "\nGenre: " + song.getGenre(); + } + if(song.getYear() != null && song.getYear() != 0) { + msg += "\nYear: " + song.getYear(); + } + if(!Util.isOffline(context) && song.getSuffix() != null) { + msg += "\nServer Format: " + song.getSuffix(); + if(song.getBitRate() != null && song.getBitRate() != 0) { + msg += "\nServer Bitrate: " + song.getBitRate() + " kbps"; + } + } + if(format != null && !"".equals(format)) { + msg += "\nCached Format: " + format; + } + if(bitrate != null && bitrate != 0) { + msg += "\nCached Bitrate: " + bitrate + " kbps"; + } + if(size != 0) { + msg += "\nSize: " + Util.formatLocalizedBytes(size, context); + } + if(song.getDuration() != null && song.getDuration() != 0) { + msg += "\nLength: " + Util.formatDuration(song.getDuration()); + } + if(song.getBookmark() != null) { + msg += "\nBookmark Position: " + Util.formatDuration(song.getBookmark().getPosition() / 1000); + } + if(song.getRating() != 0) { + msg += "\nRating: " + song.getRating() + " stars"; + } + if(song instanceof PodcastEpisode) { + msg += "\n\nDescription: " + song.getAlbum(); + } + + Util.info(context, song.getTitle(), msg); + } + + protected void playVideo(Entry entry) { + if(entryExists(entry)) { + playExternalPlayer(entry); + } else { + streamExternalPlayer(entry); + } + } + + protected void playWebView(Entry entry) { + int maxBitrate = Util.getMaxVideoBitrate(context); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(MusicServiceFactory.getMusicService(context).getVideoUrl(maxBitrate, context, entry.getId()))); + + startActivity(intent); + } + protected void playExternalPlayer(Entry entry) { + if(!entryExists(entry)) { + Util.toast(context, R.string.download_need_download); + } else { + DownloadFile check = new DownloadFile(context, entry, false); + File file = check.getCompleteFile(); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(Uri.fromFile(file), "video/*"); + intent.putExtra(Intent.EXTRA_TITLE, entry.getTitle()); + + List intents = context.getPackageManager() + .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + if(intents != null && intents.size() > 0) { + startActivity(intent); + }else { + Util.toast(context, R.string.download_no_streaming_player); + } + } + } + protected void streamExternalPlayer(Entry entry) { + String videoPlayerType = Util.getVideoPlayerType(context); + if("flash".equals(videoPlayerType)) { + playWebView(entry); + } else if("hls".equals(videoPlayerType)) { + streamExternalPlayer(entry, "hls"); + } else if("raw".equals(videoPlayerType)) { + streamExternalPlayer(entry, "raw"); + } else { + streamExternalPlayer(entry, entry.getTranscodedSuffix()); + } + } + protected void streamExternalPlayer(Entry entry, String format) { + try { + int maxBitrate = Util.getMaxVideoBitrate(context); + + Intent intent = new Intent(Intent.ACTION_VIEW); + if("hls".equals(format)) { + intent.setDataAndType(Uri.parse(MusicServiceFactory.getMusicService(context).getHlsUrl(entry.getId(), maxBitrate, context)), "application/x-mpegURL"); + } else { + intent.setDataAndType(Uri.parse(MusicServiceFactory.getMusicService(context).getVideoStreamUrl(format, maxBitrate, context, entry.getId())), "video/*"); + } + intent.putExtra("title", entry.getTitle()); + + List intents = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + if(intents != null && intents.size() > 0) { + startActivity(intent); + } else { + Util.toast(context, R.string.download_no_streaming_player); + } + } catch(Exception error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = error.getMessage(); + } else { + msg = context.getResources().getString(R.string.download_no_streaming_player) + " " + error.getMessage(); + } + + Util.toast(context, msg, false); + } + } + + protected boolean entryExists(Entry entry) { + DownloadFile check = new DownloadFile(context, entry, false); + return check.isCompleteFileAvailable(); + } + + public void deleteRecursively(Artist artist) { + deleteRecursively(FileUtil.getArtistDirectory(context, artist)); + } + + public void deleteRecursively(Entry album) { + deleteRecursively(FileUtil.getAlbumDirectory(context, album)); + + } + public void deleteRecursively(final File dir) { + if(dir == null) { + return; + } + + new LoadingTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MediaStoreService mediaStore = new MediaStoreService(context); + FileUtil.recursiveDelete(dir, mediaStore); + return null; + } + + @Override + protected void done(Void result) { + if(Util.isOffline(context)) { + refresh(); + } else { + UpdateView.triggerUpdate(); + } + } + }.execute(); + } + + public void showAlbumArtist(Entry entry) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + if(Util.isTagBrowsing(context)) { + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getArtistId()); + } else { + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getParent()); + } + args.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.getArtist()); + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + public void showArtist(Entry entry) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + if(Util.isTagBrowsing(context)) { + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getArtistId()); + } else { + if(entry.getGrandParent() == null) { + args.putString(Constants.INTENT_EXTRA_NAME_CHILD_ID, entry.getParent()); + } else { + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getGrandParent()); + } + } + args.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.getArtist()); + args.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true); + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + public void showAlbum(Entry entry) { + SubsonicFragment fragment = new SelectDirectoryFragment(); + Bundle args = new Bundle(); + if(Util.isTagBrowsing(context)) { + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getAlbumId()); + } else { + args.putString(Constants.INTENT_EXTRA_NAME_ID, entry.getParent()); + } + args.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.getAlbum()); + fragment.setArguments(args); + + replaceFragment(fragment, true); + } + + public void createShare(final List entries) { + new LoadingTask>(context, true) { + @Override + protected List doInBackground() throws Throwable { + List ids = new ArrayList(entries.size()); + for(Entry entry: entries) { + ids.add(entry.getId()); + } + + MusicService musicService = MusicServiceFactory.getMusicService(context); + return musicService.createShare(ids, null, 0L, context, this); + } + + @Override + protected void done(final List shares) { + if(shares.size() > 0) { + Share share = shares.get(0); + shareExternal(share); + } else { + Util.toast(context, context.getResources().getString(R.string.playlist_error), false); + } + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.playlist_error) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + public void shareExternal(Share share) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, share.getUrl()); + context.startActivity(Intent.createChooser(intent, context.getResources().getString(R.string.share_via))); + } + + public GestureDetector getGestureDetector() { + return gestureScanner; + } + + protected void playBookmark(List songs, Entry song) { + playBookmark(songs, song, null, null); + } + protected void playBookmark(final List songs, final Entry song, final String playlistName, final String playlistId) { + final Integer position = song.getBookmark().getPosition(); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.bookmark_resume_title) + .setMessage(getResources().getString(R.string.bookmark_resume, song.getTitle(), Util.formatDuration(position / 1000))) + .setPositiveButton(R.string.bookmark_action_resume, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + playNow(songs, song, position); + } + }) + .setNegativeButton(R.string.bookmark_action_start_over, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + final Bookmark oldBookmark = song.getBookmark(); + song.setBookmark(null); + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deleteBookmark(song, context, null); + + return null; + } + + @Override + protected void error(Throwable error) { + song.setBookmark(oldBookmark); + + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.bookmark_deleted_error, song.getTitle()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + + playNow(songs, 0); + } + }); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + protected void playNow(List entries) { + playNow(entries, null, null); + } + protected void playNow(List entries, String playlistName, String playlistId) { + Entry bookmark = null; + for(Entry entry: entries) { + if(entry.getBookmark() != null) { + bookmark = entry; + break; + } + } + + // If no bookmark found, just play from start + if(bookmark == null) { + playNow(entries, 0, playlistName, playlistId); + } else { + // If bookmark found, then give user choice to start from there or to start over + playBookmark(entries, bookmark, playlistName, playlistId); + } + } + protected void playNow(List entries, int position) { + playNow(entries, position, null, null); + } + protected void playNow(List entries, int position, String playlistName, String playlistId) { + Entry selected = entries.isEmpty() ? null : entries.get(0); + playNow(entries, selected, position, playlistName, playlistId); + } + protected void playNow(List entries, Entry song, int position) { + playNow(entries, song, position, null, null); + } + protected void playNow(final List entries, final Entry song, final int position, final String playlistName, final String playlistId) { + new LoadingTask(context) { + @Override + protected Void doInBackground() throws Throwable { + DownloadService downloadService = getDownloadService(); + if(downloadService == null) { + return null; + } + + downloadService.clear(); + downloadService.download(entries, false, true, true, false, entries.indexOf(song), position); + downloadService.setSuggestedPlaylistName(playlistName, playlistId); + + return null; + } + + @Override + protected void done(Void result) { + Util.startActivityWithoutTransition(context, DownloadActivity.class); + } + }.execute(); + } + + protected void deleteBookmark(final MusicDirectory.Entry entry, final ArrayAdapter adapter) { + Util.confirmDialog(context, R.string.bookmark_delete_title, entry.getTitle(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + final Bookmark oldBookmark = entry.getBookmark(); + entry.setBookmark(null); + + new LoadingTask(context, false) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deleteBookmark(entry, context, null); + + new EntryInstanceUpdater(entry) { + @Override + public void update(Entry found) { + found.setBookmark(null); + } + }.execute(); + + return null; + } + + @Override + protected void done(Void result) { + if (adapter != null) { + adapter.remove(entry); + adapter.notifyDataSetChanged(); + } + Util.toast(context, context.getResources().getString(R.string.bookmark_deleted, entry.getTitle())); + } + + @Override + protected void error(Throwable error) { + entry.setBookmark(oldBookmark); + + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.bookmark_deleted_error, entry.getTitle()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + }); + } + + protected void setRating(Entry entry) { + setRating(entry, null); + } + protected void setRating(final Entry entry, final OnRatingChange onRatingChange) { + View layout = context.getLayoutInflater().inflate(R.layout.rating, null); + final RatingBar ratingBar = (RatingBar) layout.findViewById(R.id.rating_bar); + ratingBar.setRating((float) entry.getRating()); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(context.getResources().getString(R.string.rating_title, entry.getTitle())) + .setView(layout) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + int rating = (int) ratingBar.getRating(); + setRating(entry, rating, onRatingChange); + } + }) + .setNegativeButton(R.string.common_cancel, null); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + protected void setRating(Entry entry, int rating) { + setRating(entry, rating, null); + } + protected void setRating(final Entry entry, final int rating, final OnRatingChange onRatingChange) { + final int oldRating = entry.getRating(); + entry.setRating(rating); + + if(onRatingChange != null) { + onRatingChange.ratingChange(rating); + } + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.setRating(entry, rating, context, null); + + new EntryInstanceUpdater(entry) { + @Override + public void update(Entry found) { + found.setRating(rating); + } + }.execute(); + return null; + } + + @Override + protected void done(Void result) { + Util.toast(context, context.getResources().getString(rating > 0 ? R.string.rating_set_rating : R.string.rating_remove_rating, entry.getTitle())); + } + + @Override + protected void error(Throwable error) { + entry.setRating(oldRating); + if(onRatingChange != null) { + onRatingChange.ratingChange(oldRating); + } + + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(rating > 0 ? R.string.rating_set_rating_failed : R.string.rating_remove_rating_failed, entry.getTitle()) + " " + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + + protected abstract class EntryInstanceUpdater { + private Entry entry; + + public EntryInstanceUpdater(Entry entry) { + this.entry = entry; + } + + public abstract void update(Entry found); + + public void execute() { + DownloadService downloadService = getDownloadService(); + if(downloadService != null && !entry.isDirectory()) { + boolean serializeChanges = false; + List downloadFiles = downloadService.getDownloads(); + for(DownloadFile file: downloadFiles) { + Entry check = file.getSong(); + if(entry.getId().equals(check.getId())) { + update(entry); + serializeChanges = true; + } + } + + if(serializeChanges) { + downloadService.serializeQueue(); + } + } + + Entry find = UpdateView.findEntry(entry); + if(find != null) { + update(find); + } + } + } + + public abstract class OnRatingChange { + abstract void ratingChange(int rating); + } + public abstract class OnStarChange { + abstract void starChange(boolean starred); + } + + public abstract class RecursiveLoader extends LoadingTask { + protected MusicService musicService; + protected static final int MAX_SONGS = 500; + protected boolean playNowOverride = false; + protected List songs; + + public RecursiveLoader(Activity context) { + super(context); + } + + protected void getSongsRecursively(MusicDirectory parent, List songs) throws Exception { + if (songs.size() > MAX_SONGS) { + return; + } + + for (Entry song : parent.getChildren(false, true)) { + if (!song.isVideo() && song.getRating() != 1) { + songs.add(song); + } + } + for (Entry dir : parent.getChildren(true, false)) { + if(dir.getRating() == 1) { + continue; + } + + MusicDirectory musicDirectory; + if(Util.isTagBrowsing(context) && !Util.isOffline(context)) { + musicDirectory = musicService.getAlbum(dir.getId(), dir.getTitle(), false, context, this); + } else { + musicDirectory = musicService.getMusicDirectory(dir.getId(), dir.getTitle(), false, context, this); + } + getSongsRecursively(musicDirectory, songs); + } + } + + @Override + protected void done(Boolean result) { + warnIfStorageUnavailable(); + + if(playNowOverride) { + playNow(songs); + return; + } + + if(result) { + Util.startActivityWithoutTransition(context, DownloadActivity.class); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/fragments/UserFragment.java b/app/src/main/java/github/daneren2005/dsub/fragments/UserFragment.java new file mode 100644 index 00000000..00c7c603 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/fragments/UserFragment.java @@ -0,0 +1,125 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v4.widget.SwipeRefreshLayout; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.UserUtil; +import github.daneren2005.dsub.adapter.SettingsAdapter; + +public class UserFragment extends SubsonicFragment{ + private ListView listView; + private User user; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + rootView = inflater.inflate(R.layout.abstract_list_fragment, container, false); + + refreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.refresh_layout); + refreshLayout.setEnabled(false); + + Bundle args = getArguments(); + user = (User) args.getSerializable(Constants.INTENT_EXTRA_NAME_ID); + + listView = (ListView)rootView.findViewById(R.id.fragment_list); + createHeader(); + listView.setAdapter(new SettingsAdapter(context, user.getSettings(), UserUtil.isCurrentAdmin() && ServerInfo.checkServerVersion(context, "1.10"))); + + setTitle(user.getUsername()); + + return rootView; + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + ((SubsonicActivity) activity).supportInvalidateOptionsMenu(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + // For some reason this is called before onAttach + if(!primaryFragment || context == null) { + return; + } + + if(UserUtil.isCurrentAdmin() && ServerInfo.checkServerVersion(context, "1.10")) { + menuInflater.inflate(R.menu.user, menu); + } else if(UserUtil.isCurrentRole(User.SETTINGS)) { + menuInflater.inflate(R.menu.user_user, menu); + } else { + menuInflater.inflate(R.menu.empty, menu); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if(super.onOptionsItemSelected(item)) { + return true; + } + + switch (item.getItemId()) { + case R.id.menu_update_permissions: + UserUtil.updateSettings(context, user); + return true; + case R.id.menu_change_password: + UserUtil.changePassword(context, user); + return true; + case R.id.menu_change_email: + UserUtil.changeEmail(context, user); + return true; + } + + return false; + } + + private void createHeader() { + View header = LayoutInflater.from(context).inflate(R.layout.user_header, listView, false); + + final ImageLoader imageLoader = getImageLoader(); + ImageView coverArtView = (ImageView) header.findViewById(R.id.user_avatar); + imageLoader.loadAvatar(context, coverArtView, user.getUsername()); + + TextView usernameView = (TextView) header.findViewById(R.id.user_username); + usernameView.setText(user.getUsername()); + + final TextView emailView = (TextView) header.findViewById(R.id.user_email); + if(user.getEmail() != null) { + emailView.setText(user.getEmail()); + } else { + emailView.setVisibility(View.GONE); + } + + listView.addHeaderView(header); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DLNARouteProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/DLNARouteProvider.java new file mode 100644 index 00000000..73d4b5de --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DLNARouteProvider.java @@ -0,0 +1,425 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2014 (C) Scott Jackson +*/ +package github.daneren2005.dsub.provider; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.media.AudioManager; +import android.media.MediaRouter; +import android.os.IBinder; +import android.support.v7.media.MediaControlIntent; +import android.support.v7.media.MediaRouteDescriptor; +import android.support.v7.media.MediaRouteDiscoveryRequest; +import android.support.v7.media.MediaRouteProvider; +import android.support.v7.media.MediaRouteProviderDescriptor; +import android.util.Log; + +import org.eclipse.jetty.util.log.Logger; +import org.fourthline.cling.android.AndroidUpnpService; +import org.fourthline.cling.android.AndroidUpnpServiceImpl; +import org.fourthline.cling.model.action.ActionInvocation; +import org.fourthline.cling.model.message.UpnpResponse; +import org.fourthline.cling.model.meta.Device; +import org.fourthline.cling.model.meta.LocalDevice; +import org.fourthline.cling.model.meta.RemoteDevice; +import org.fourthline.cling.model.meta.StateVariable; +import org.fourthline.cling.model.meta.StateVariableAllowedValueRange; +import org.fourthline.cling.model.types.ServiceType; +import org.fourthline.cling.registry.Registry; +import org.fourthline.cling.registry.RegistryListener; +import org.fourthline.cling.support.renderingcontrol.callback.GetVolume; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import github.daneren2005.dsub.domain.DLNADevice; +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.service.DLNAController; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.RemoteController; + +public class DLNARouteProvider extends MediaRouteProvider { + private static final String TAG = DLNARouteProvider.class.getSimpleName(); + public static final String CATEGORY_DLNA = "github.daneren2005.dsub.DLNA"; + + private DownloadService downloadService; + private RemoteController controller; + + private HashMap devices = new HashMap(); + private List adding = new ArrayList(); + private List removing = new ArrayList(); + private AndroidUpnpService dlnaService; + private ServiceConnection dlnaServiceConnection; + private boolean searchOnConnect = false; + + public DLNARouteProvider(Context context) { + super(context); + + // Use custom logger + org.eclipse.jetty.util.log.Log.setLog(new JettyAndroidLog()); + + this.downloadService = (DownloadService) context; + dlnaServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + dlnaService = (AndroidUpnpService) service; + dlnaService.getRegistry().addListener(new RegistryListener() { + @Override + public void remoteDeviceDiscoveryStarted(Registry registry, RemoteDevice remoteDevice) { + + } + + @Override + public void remoteDeviceDiscoveryFailed(Registry registry, RemoteDevice remoteDevice, Exception e) { + // Error is displayed in log anyways under W/trieveRemoteDescriptors + } + + @Override + public void remoteDeviceAdded(Registry registry, RemoteDevice remoteDevice) { + deviceAdded(remoteDevice); + } + + @Override + public void remoteDeviceUpdated(Registry registry, RemoteDevice remoteDevice) { + deviceAdded(remoteDevice); + } + + @Override + public void remoteDeviceRemoved(Registry registry, RemoteDevice remoteDevice) { + deviceRemoved(remoteDevice); + } + + @Override + public void localDeviceAdded(Registry registry, LocalDevice localDevice) { + deviceAdded(localDevice); + } + + @Override + public void localDeviceRemoved(Registry registry, LocalDevice localDevice) { + deviceRemoved(localDevice); + } + + @Override + public void beforeShutdown(Registry registry) { + + } + + @Override + public void afterShutdown() { + + } + }); + + for (Device device : dlnaService.getControlPoint().getRegistry().getDevices()) { + deviceAdded(device); + } + if(searchOnConnect) { + dlnaService.getControlPoint().search(); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + dlnaService = null; + } + }; + + if(!context.getApplicationContext().bindService(new Intent(context, AndroidUpnpServiceImpl.class), dlnaServiceConnection, Context.BIND_AUTO_CREATE)) { + Log.e(TAG, "Failed to bind to DLNA service"); + } + } + + private void broadcastDescriptors() { + // Create intents + IntentFilter routeIntentFilter = new IntentFilter(); + routeIntentFilter.addCategory(CATEGORY_DLNA); + routeIntentFilter.addAction(MediaControlIntent.ACTION_START_SESSION); + routeIntentFilter.addAction(MediaControlIntent.ACTION_GET_SESSION_STATUS); + routeIntentFilter.addAction(MediaControlIntent.ACTION_END_SESSION); + + // Create descriptor + MediaRouteProviderDescriptor.Builder providerBuilder = new MediaRouteProviderDescriptor.Builder(); + + // Create route descriptor + for(Map.Entry deviceEntry: devices.entrySet()) { + DLNADevice device = deviceEntry.getValue(); + + int volume; + if(device.volumeMax == 0) { + volume = 5; + } else { + int increments = device.volumeMax / 10; + volume = controller == null ? device.volume : (int) controller.getVolume(); + volume = volume / increments; + } + + MediaRouteDescriptor.Builder routeBuilder = new MediaRouteDescriptor.Builder(device.id, device.name); + routeBuilder.addControlFilter(routeIntentFilter) + .setPlaybackStream(AudioManager.STREAM_MUSIC) + .setPlaybackType(MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) + .setDescription(device.description) + .setVolume(volume) + .setVolumeMax(10) + .setVolumeHandling(MediaRouter.RouteInfo.PLAYBACK_VOLUME_VARIABLE); + providerBuilder.addRoute(routeBuilder.build()); + } + + setDescriptor(providerBuilder.build()); + } + + @Override + public void onDiscoveryRequestChanged(MediaRouteDiscoveryRequest request) { + if (request != null && request.isActiveScan()) { + if(dlnaService != null) { + dlnaService.getControlPoint().search(); + } else { + searchOnConnect = true; + } + } + } + + @Override + public RouteController onCreateRouteController(String routeId) { + DLNADevice device = devices.get(routeId); + if(device == null) { + Log.w(TAG, "No device exists for " + routeId); + return null; + } + + return new DLNARouteController(device); + } + + private void deviceAdded(final Device device) { + final org.fourthline.cling.model.meta.Service renderingControl = device.findService(new ServiceType("schemas-upnp-org", "RenderingControl")); + if(renderingControl == null) { + return; + } + + final String id = device.getIdentity().getUdn().toString(); + // In the process of looking up it's details already + if(adding.contains(id)) { + return; + } + // Just a temp disconnect, already have it's info + if(removing.contains(id)) { + removing.remove(id); + return; + } + adding.add(id); + + if(device.getType().getType().equals("MediaRenderer") && device instanceof RemoteDevice) { + try { + dlnaService.getControlPoint().execute(new GetVolume(renderingControl) { + @Override + public void received(ActionInvocation actionInvocation, int currentVolume) { + int maxVolume = 100; + StateVariable volume = renderingControl.getStateVariable("Volume"); + if (volume != null) { + StateVariableAllowedValueRange volumeRange = volume.getTypeDetails().getAllowedValueRange(); + maxVolume = (int) volumeRange.getMaximum(); + } + + // Create a new DLNADevice to represent this item + String id = device.getIdentity().getUdn().toString(); + String name = device.getDetails().getFriendlyName(); + String displayName = device.getDisplayString(); + + DLNADevice newDevice = new DLNADevice(device, id, name, displayName, currentVolume, maxVolume); + devices.put(id, newDevice); + downloadService.post(new Runnable() { + @Override + public void run() { + broadcastDescriptors(); + } + }); + adding.remove(id); + } + + @Override + public void failure(ActionInvocation actionInvocation, UpnpResponse upnpResponse, String s) { + Log.w(TAG, "Failed to get default volume for DLNA route"); + Log.w(TAG, "Reason: " + s); + adding.remove(id); + } + }); + } catch(Exception e) { + Log.e(TAG, "Failed to add device", e); + } + } else { + adding.remove(id); + } + } + private void deviceRemoved(Device device) { + if(device.getType().getType().equals("MediaRenderer") && device instanceof RemoteDevice) { + final String id = device.getIdentity().getUdn().toString(); + removing.add(id); + + // Delay removal for a few seconds to make sure that it isn't just a temp disconnect + dlnaService.getControlPoint().search(); + downloadService.postDelayed(new Runnable() { + @Override + public void run() { + if(removing.contains(id)) { + devices.remove(id); + removing.remove(id); + broadcastDescriptors(); + } + } + }, 5000L); + } + } + + private class DLNARouteController extends RouteController { + private DLNADevice device; + + public DLNARouteController(DLNADevice device) { + this.device = device; + } + + @Override + public boolean onControlRequest(Intent intent, android.support.v7.media.MediaRouter.ControlRequestCallback callback) { + if (intent.hasCategory(CATEGORY_DLNA)) { + return true; + } else { + return false; + } + } + + @Override + public void onRelease() { + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + controller = null; + } + + @Override + public void onSelect() { + controller = new DLNAController(downloadService, dlnaService.getControlPoint(), device); + downloadService.setRemoteEnabled(RemoteControlState.DLNA, controller); + } + + @Override + public void onUnselect() { + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + controller = null; + } + + @Override + public void onUpdateVolume(int delta) { + if(controller != null) { + controller.updateVolume(delta > 0); + } + broadcastDescriptors(); + } + + @Override + public void onSetVolume(int volume) { + if(controller != null) { + controller.setVolume(volume); + } + broadcastDescriptors(); + } + } + + public static class JettyAndroidLog implements Logger { + final private static java.util.logging.Logger log = java.util.logging.Logger.getLogger("Jetty"); + + public static boolean __isIgnoredEnabled = false; + public String _name; + + public JettyAndroidLog() { + this (JettyAndroidLog.class.getName()); + } + + public JettyAndroidLog(String name) { + _name = name; + } + + public String getName () { + return _name; + } + + public void debug(Throwable th) { + // Log.d(TAG, "", th); + } + + public void debug(String msg, Throwable th) { + // Log.d(TAG, msg, th); + } + + public void debug(String msg, Object... args) { + // Log.d(TAG, msg); + } + + public Logger getLogger(String name) { + return new JettyAndroidLog(name); + } + + public void info(String msg, Object... args) { + // Log.i(TAG, msg); + } + + public void info(Throwable th) { + // Log.i(TAG, "", th); + } + + public void info(String msg, Throwable th) { + // Log.i(TAG, msg, th); + } + + public boolean isDebugEnabled() { + return false; + } + + public void warn(Throwable th) { + // Log.w(TAG, "", th); + } + + public void warn(String msg, Object... args) { + // Log.w(TAG, msg); + } + + public void warn(String msg, Throwable th) { + // Log.w(TAG, msg, th); + } + + public boolean isIgnoredEnabled () { + return __isIgnoredEnabled; + } + + + public void ignore(Throwable ignored) { + if (__isIgnoredEnabled) { + warn("IGNORED", ignored); + } + } + + public void setIgnoredEnabled(boolean enabled) { + __isIgnoredEnabled = enabled; + } + + public void setDebugEnabled(boolean enabled) { + + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DSubSearchProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/DSubSearchProvider.java new file mode 100644 index 00000000..63bbaaa4 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DSubSearchProvider.java @@ -0,0 +1,191 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.provider; + +import android.app.SearchManager; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.SearchCritera; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.Util; + +/** + * Provides search suggestions based on recent searches. + * + * @author Sindre Mehus + */ +public class DSubSearchProvider extends ContentProvider { + private static final String TAG = DSubSearchProvider.class.getSimpleName(); + + private static final String RESOURCE_PREFIX = "android.resource://github.daneren2005.dsub/"; + private static final String[] COLUMNS = {"_id", + SearchManager.SUGGEST_COLUMN_TEXT_1, + SearchManager.SUGGEST_COLUMN_TEXT_2, + SearchManager.SUGGEST_COLUMN_INTENT_DATA, + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, + SearchManager.SUGGEST_COLUMN_ICON_1}; + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + String query = selectionArgs[0] + "*"; + SearchResult searchResult = search(query); + return createCursor(selectionArgs[0], searchResult); + } + + private SearchResult search(String query) { + MusicService musicService = MusicServiceFactory.getMusicService(getContext()); + if (musicService == null) { + return null; + } + + try { + return musicService.search(new SearchCritera(query, 5, 10, 10), getContext(), null); + } catch (Exception e) { + return null; + } + } + + private Cursor createCursor(String query, SearchResult searchResult) { + MatrixCursor cursor = new MatrixCursor(COLUMNS); + if (searchResult == null) { + return cursor; + } + + // Add all results into one pot + List results = new ArrayList(); + results.addAll(searchResult.getArtists()); + results.addAll(searchResult.getAlbums()); + results.addAll(searchResult.getSongs()); + + // For each, calculate its string distance to the query + for(Object obj: results) { + if(obj instanceof Artist) { + Artist artist = (Artist) obj; + artist.setCloseness(Util.getStringDistance(query, artist.getName())); + } else { + MusicDirectory.Entry entry = (MusicDirectory.Entry) obj; + entry.setCloseness(Util.getStringDistance(query, entry.getTitle())); + } + } + + // Sort based on the closeness paramater + Collections.sort(results, new Comparator() { + @Override + public int compare(Object lhs, Object rhs) { + // Get the closeness of the two objects + int left, right; + boolean leftArtist = lhs instanceof Artist; + boolean rightArtist = rhs instanceof Artist; + if (leftArtist) { + left = ((Artist) lhs).getCloseness(); + } else { + left = ((MusicDirectory.Entry) lhs).getCloseness(); + } + if (rightArtist) { + right = ((Artist) rhs).getCloseness(); + } else { + right = ((MusicDirectory.Entry) rhs).getCloseness(); + } + + if (left == right) { + if(leftArtist && rightArtist) { + return 0; + } else if(leftArtist) { + return -1; + } else if(rightArtist) { + return 1; + } else { + return 0; + } + } else if (left > right) { + return 1; + } else { + return -1; + } + } + }); + + // Done sorting, add results to cursor + for(Object obj: results) { + if(obj instanceof Artist) { + Artist artist = (Artist) obj; + String icon = RESOURCE_PREFIX + R.drawable.ic_action_artist; + cursor.addRow(new Object[]{artist.getId().hashCode(), artist.getName(), null, "ar-" + artist.getId(), artist.getName(), icon}); + } else { + MusicDirectory.Entry entry = (MusicDirectory.Entry) obj; + + if(entry.isDirectory()) { + String icon = RESOURCE_PREFIX + R.drawable.ic_action_album; + cursor.addRow(new Object[]{entry.getId().hashCode(), entry.getTitle(), entry.getArtist(), entry.getId(), entry.getTitle(), icon}); + } else { + String icon = RESOURCE_PREFIX + R.drawable.ic_action_song; + String id; + if(Util.isTagBrowsing(getContext())) { + id = entry.getAlbumId(); + } else { + id = entry.getParent(); + } + cursor.addRow(new Object[]{entry.getId().hashCode(), entry.getTitle(), entry.getArtist(), "so-" + id, entry.getTitle(), icon}); + } + } + } + return cursor; + } + + @Override + public boolean onCreate() { + return false; + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues contentValues) { + return null; + } + + @Override + public int delete(Uri uri, String s, String[] strings) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues contentValues, String s, String[] strings) { + return 0; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x1.java b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x1.java new file mode 100644 index 00000000..c78257c4 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x1.java @@ -0,0 +1,28 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.provider; + +import github.daneren2005.dsub.R; + +public class DSubWidget4x1 extends DSubWidgetProvider { + @Override + protected int getLayout() { + return R.layout.appwidget4x1; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x2.java b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x2.java new file mode 100644 index 00000000..4c7d637c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x2.java @@ -0,0 +1,28 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.provider; + +import github.daneren2005.dsub.R; + +public class DSubWidget4x2 extends DSubWidgetProvider { + @Override + protected int getLayout() { + return R.layout.appwidget4x2; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x3.java b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x3.java new file mode 100644 index 00000000..b4f91a45 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x3.java @@ -0,0 +1,28 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.provider; + +import github.daneren2005.dsub.R; + +public class DSubWidget4x3 extends DSubWidgetProvider { + @Override + protected int getLayout() { + return R.layout.appwidget4x3; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x4.java b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x4.java new file mode 100644 index 00000000..a591ff29 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidget4x4.java @@ -0,0 +1,28 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.provider; + +import github.daneren2005.dsub.R; + +public class DSubWidget4x4 extends DSubWidgetProvider { + @Override + protected int getLayout() { + return R.layout.appwidget4x4; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/DSubWidgetProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidgetProvider.java new file mode 100644 index 00000000..444b6cff --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/DSubWidgetProvider.java @@ -0,0 +1,304 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.provider; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff.Mode; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Environment; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.DownloadActivity; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.DownloadServiceLifecycleSupport; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.Util; + +/** + * Simple widget to show currently playing album art along + * with play/pause and next track buttons. + *

+ * Based on source code from the stock Android Music app. + * + * @author Sindre Mehus + */ +public class DSubWidgetProvider extends AppWidgetProvider { + private static final String TAG = DSubWidgetProvider.class.getSimpleName(); + private static DSubWidget4x1 instance4x1; + private static DSubWidget4x2 instance4x2; + private static DSubWidget4x3 instance4x3; + private static DSubWidget4x4 instance4x4; + + public static synchronized void notifyInstances(Context context, DownloadService service, boolean playing) { + if(instance4x1 == null) { + instance4x1 = new DSubWidget4x1(); + } + if(instance4x2 == null) { + instance4x2 = new DSubWidget4x2(); + } + if(instance4x3 == null) { + instance4x3 = new DSubWidget4x3(); + } + if(instance4x4 == null) { + instance4x4 = new DSubWidget4x4(); + } + + instance4x1.notifyChange(context, service, playing); + instance4x2.notifyChange(context, service, playing); + instance4x3.notifyChange(context, service, playing); + instance4x4.notifyChange(context, service, playing); + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + defaultAppWidget(context, appWidgetIds); + } + + @Override + public void onEnabled(Context context) { + notifyInstances(context, DownloadService.getInstance(), false); + } + + protected int getLayout() { + return 0; + } + + /** + * Initialize given widgets to default state, where we launch Subsonic on default click + * and hide actions if service not running. + */ + private void defaultAppWidget(Context context, int[] appWidgetIds) { + final Resources res = context.getResources(); + final RemoteViews views = new RemoteViews(context.getPackageName(), getLayout()); + + views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text)); + if(getLayout() == R.layout.appwidget4x2) { + views.setTextViewText(R.id.album, ""); + } + + linkButtons(context, views, false); + performUpdate(context, null, appWidgetIds, false); + } + + private void pushUpdate(Context context, int[] appWidgetIds, RemoteViews views) { + // Update specific list of appWidgetIds if given, otherwise default to all + final AppWidgetManager manager = AppWidgetManager.getInstance(context); + if (appWidgetIds != null) { + manager.updateAppWidget(appWidgetIds, views); + } else { + manager.updateAppWidget(new ComponentName(context, this.getClass()), views); + } + } + + /** + * Handle a change notification coming over from {@link DownloadService} + */ + public void notifyChange(Context context, DownloadService service, boolean playing) { + if (hasInstances(context)) { + performUpdate(context, service, null, playing); + } + } + + /** + * Check against {@link AppWidgetManager} if there are any instances of this widget. + */ + private boolean hasInstances(Context context) { + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] appWidgetIds = manager.getAppWidgetIds(new ComponentName(context, getClass())); + return (appWidgetIds.length > 0); + } + + /** + * Update all active widget instances by pushing changes + */ + private void performUpdate(Context context, DownloadService service, int[] appWidgetIds, boolean playing) { + final Resources res = context.getResources(); + final RemoteViews views = new RemoteViews(context.getPackageName(), getLayout()); + + if(playing) { + views.setViewVisibility(R.id.widget_root, View.VISIBLE); + } else { + // Hide widget + SharedPreferences prefs = Util.getPreferences(context); + if(prefs.getBoolean(Constants.PREFERENCES_KEY_HIDE_WIDGET, false)) { + views.setViewVisibility(R.id.widget_root, View.GONE); + } + } + + // Get Entry from current playing DownloadFile + MusicDirectory.Entry currentPlaying = null; + if(service == null) { + // Deserialize from playling list to setup + PlayerQueue state = FileUtil.deserialize(context, DownloadServiceLifecycleSupport.FILENAME_DOWNLOADS_SER, PlayerQueue.class); + if(state != null && state.currentPlayingIndex != -1) { + currentPlaying = state.songs.get(state.currentPlayingIndex); + } + } else { + currentPlaying = service.getCurrentPlaying() == null ? null : service.getCurrentPlaying().getSong(); + } + + String title = currentPlaying == null ? null : currentPlaying.getTitle(); + CharSequence artist = currentPlaying == null ? null : currentPlaying.getArtist(); + CharSequence album = currentPlaying == null ? null : currentPlaying.getAlbum(); + CharSequence errorState = null; + + // Show error message? + String status = Environment.getExternalStorageState(); + if (status.equals(Environment.MEDIA_SHARED) || + status.equals(Environment.MEDIA_UNMOUNTED)) { + errorState = res.getText(R.string.widget_sdcard_busy); + } else if (status.equals(Environment.MEDIA_REMOVED)) { + errorState = res.getText(R.string.widget_sdcard_missing); + } else if (currentPlaying == null) { + errorState = res.getText(R.string.widget_initial_text); + } + + if (errorState != null) { + // Show error state to user + views.setTextViewText(R.id.title,null); + views.setTextViewText(R.id.artist, errorState); + views.setTextViewText(R.id.album, ""); + if(getLayout() != R.layout.appwidget4x1) { + views.setImageViewResource(R.id.appwidget_coverart, R.drawable.appwidget_art_default); + } + } else { + // No error, so show normal titles + views.setTextViewText(R.id.title, title); + views.setTextViewText(R.id.artist, artist); + if(getLayout() != R.layout.appwidget4x1) { + views.setTextViewText(R.id.album, album); + } + } + + // Set correct drawable for pause state + if (playing) { + views.setImageViewResource(R.id.control_play, R.drawable.ic_appwidget_music_pause); + } else { + views.setImageViewResource(R.id.control_play, R.drawable.ic_appwidget_music_play); + } + + // Set the cover art + try { + boolean large = false; + if(getLayout() != R.layout.appwidget4x1 && getLayout() != R.layout.appwidget4x2) { + large = true; + } + ImageLoader imageLoader = SubsonicActivity.getStaticImageLoader(context); + Bitmap bitmap = imageLoader == null ? null : imageLoader.getCachedImage(context, currentPlaying, large); + + if (bitmap == null) { + // Set default cover art + views.setImageViewResource(R.id.appwidget_coverart, R.drawable.appwidget_art_unknown); + } else { + bitmap = getRoundedCornerBitmap(bitmap); + views.setImageViewBitmap(R.id.appwidget_coverart, bitmap); + } + } catch (Exception x) { + Log.e(TAG, "Failed to load cover art", x); + views.setImageViewResource(R.id.appwidget_coverart, R.drawable.appwidget_art_unknown); + } + + // Link actions buttons to intents + linkButtons(context, views, currentPlaying != null); + + pushUpdate(context, appWidgetIds, views); + } + + /** + * Round the corners of a bitmap for the cover art image + */ + private static Bitmap getRoundedCornerBitmap(Bitmap bitmap) { + Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Config.ARGB_8888); + Canvas canvas = new Canvas(output); + + final int color = 0xff424242; + final Paint paint = new Paint(); + final float roundPx = 10; + + // Add extra width to the rect so the right side wont be rounded. + final Rect rect = new Rect(0, 0, bitmap.getWidth() + (int) roundPx, bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(color); + canvas.drawRoundRect(rectF, roundPx, roundPx, paint); + + paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + return output; + } + + /** + * Link up various button actions using {@link PendingIntent}. + * + * @param playerActive True if player is active in background, which means + * widget click will launch {@link DownloadActivity}, + * otherwise we launch {@link github.daneren2005.dsub.activity.SubsonicFragmentActivity}. + */ + private void linkButtons(Context context, RemoteViews views, boolean playerActive) { + Intent intent = new Intent(context, SubsonicFragmentActivity.class); + intent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD, true); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent); + views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent); + + // Emulate media button clicks. + intent = new Intent("DSub.PLAY_PAUSE"); + intent.setComponent(new ComponentName(context, DownloadService.class)); + intent.setAction(DownloadService.CMD_TOGGLEPAUSE); + pendingIntent = PendingIntent.getService(context, 0, intent, 0); + views.setOnClickPendingIntent(R.id.control_play, pendingIntent); + + intent = new Intent("DSub.NEXT"); // Use a unique action name to ensure a different PendingIntent to be created. + intent.setComponent(new ComponentName(context, DownloadService.class)); + intent.setAction(DownloadService.CMD_NEXT); + pendingIntent = PendingIntent.getService(context, 0, intent, 0); + views.setOnClickPendingIntent(R.id.control_next, pendingIntent); + + intent = new Intent("DSub.PREVIOUS"); // Use a unique action name to ensure a different PendingIntent to be created. + intent.setComponent(new ComponentName(context, DownloadService.class)); + intent.setAction(DownloadService.CMD_PREVIOUS); + pendingIntent = PendingIntent.getService(context, 0, intent, 0); + views.setOnClickPendingIntent(R.id.control_previous, pendingIntent); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/JukeboxRouteProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/JukeboxRouteProvider.java new file mode 100644 index 00000000..0d2a5ff5 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/JukeboxRouteProvider.java @@ -0,0 +1,131 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2014 (C) Scott Jackson +*/ +package github.daneren2005.dsub.provider; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.media.MediaRouter; +import android.support.v7.media.MediaControlIntent; +import android.support.v7.media.MediaRouteDescriptor; +import android.support.v7.media.MediaRouteProvider; +import android.support.v7.media.MediaRouteProviderDescriptor; + +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.RemoteController; + +/** + * Created by Scott on 11/28/13. + */ +public class JukeboxRouteProvider extends MediaRouteProvider { + public static final String CATEGORY_JUKEBOX_ROUTE = "github.daneren2005.dsub.SERVER_JUKEBOX"; + private RemoteController controller; + private static final int MAX_VOLUME = 10; + + private DownloadService downloadService; + + public JukeboxRouteProvider(Context context) { + super(context); + this.downloadService = (DownloadService) context; + + broadcastDescriptor(); + } + + private void broadcastDescriptor() { + // Create intents + IntentFilter routeIntentFilter = new IntentFilter(); + routeIntentFilter.addCategory(CATEGORY_JUKEBOX_ROUTE); + routeIntentFilter.addAction(MediaControlIntent.ACTION_START_SESSION); + routeIntentFilter.addAction(MediaControlIntent.ACTION_GET_SESSION_STATUS); + routeIntentFilter.addAction(MediaControlIntent.ACTION_END_SESSION); + + // Create route descriptor + MediaRouteDescriptor.Builder routeBuilder = new MediaRouteDescriptor.Builder("Jukebox Route", "Subsonic Jukebox"); + routeBuilder.addControlFilter(routeIntentFilter) + .setPlaybackStream(AudioManager.STREAM_MUSIC) + .setPlaybackType(MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) + .setDescription("Subsonic Jukebox") + .setVolume(controller == null ? 5 : (int) (controller.getVolume() * 10)) + .setVolumeMax(MAX_VOLUME) + .setVolumeHandling(MediaRouter.RouteInfo.PLAYBACK_VOLUME_VARIABLE); + + // Create descriptor + MediaRouteProviderDescriptor.Builder providerBuilder = new MediaRouteProviderDescriptor.Builder(); + providerBuilder.addRoute(routeBuilder.build()); + setDescriptor(providerBuilder.build()); + } + + @Override + public MediaRouteProvider.RouteController onCreateRouteController(String routeId) { + return new JukeboxRouteController(downloadService); + } + + private class JukeboxRouteController extends RouteController { + private DownloadService downloadService; + + public JukeboxRouteController(DownloadService downloadService) { + this.downloadService = downloadService; + } + + @Override + public boolean onControlRequest(Intent intent, android.support.v7.media.MediaRouter.ControlRequestCallback callback) { + if (intent.hasCategory(CATEGORY_JUKEBOX_ROUTE)) { + return true; + } else { + return false; + } + } + + @Override + public void onRelease() { + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + controller = null; + } + + @Override + public void onSelect() { + downloadService.setRemoteEnabled(RemoteControlState.JUKEBOX_SERVER); + controller = downloadService.getRemoteController(); + } + + @Override + public void onUnselect() { + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + controller = null; + } + + @Override + public void onUpdateVolume(int delta) { + if(controller != null) { + controller.updateVolume(delta > 0); + } + broadcastDescriptor(); + } + + @Override + public void onSetVolume(int volume) { + if(controller != null) { + controller.setVolume(volume); + } + broadcastDescriptor(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/MostRecentStubProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/MostRecentStubProvider.java new file mode 100644 index 00000000..80dd93cc --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/MostRecentStubProvider.java @@ -0,0 +1,61 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.provider; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; + +/** + * Created by Scott on 8/28/13. + */ + +public class MostRecentStubProvider extends ContentProvider { + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return ""; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/PlaylistStubProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/PlaylistStubProvider.java new file mode 100644 index 00000000..1481557f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/PlaylistStubProvider.java @@ -0,0 +1,61 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.provider; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; + +/** + * Created by Scott on 8/28/13. + */ + +public class PlaylistStubProvider extends ContentProvider { + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return ""; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/PodcastStubProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/PodcastStubProvider.java new file mode 100644 index 00000000..7d9bfca1 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/PodcastStubProvider.java @@ -0,0 +1,61 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.provider; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; + +/** + * Created by Scott on 8/28/13. + */ + +public class PodcastStubProvider extends ContentProvider { + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return ""; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/provider/StarredStubProvider.java b/app/src/main/java/github/daneren2005/dsub/provider/StarredStubProvider.java new file mode 100644 index 00000000..f638c348 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/provider/StarredStubProvider.java @@ -0,0 +1,61 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.provider; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; + +/** + * Created by Scott on 8/28/13. + */ + +public class StarredStubProvider extends ContentProvider { + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return ""; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/receiver/A2dpIntentReceiver.java b/app/src/main/java/github/daneren2005/dsub/receiver/A2dpIntentReceiver.java new file mode 100644 index 00000000..8f6ab810 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/receiver/A2dpIntentReceiver.java @@ -0,0 +1,47 @@ +package github.daneren2005.dsub.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import github.daneren2005.dsub.service.DownloadService; + +public class A2dpIntentReceiver extends BroadcastReceiver { + private static final String PLAYSTATUS_RESPONSE = "com.android.music.playstatusresponse"; + private String TAG = A2dpIntentReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "GOT INTENT " + intent); + + DownloadService downloadService = DownloadService.getInstance(); + + if (downloadService != null){ + + Intent avrcpIntent = new Intent(PLAYSTATUS_RESPONSE); + + avrcpIntent.putExtra("duration", (long) downloadService.getPlayerDuration()); + avrcpIntent.putExtra("position", (long) downloadService.getPlayerPosition()); + avrcpIntent.putExtra("ListSize", (long) downloadService.getSongs().size()); + + switch (downloadService.getPlayerState()){ + case STARTED: + avrcpIntent.putExtra("playing", true); + break; + case STOPPED: + avrcpIntent.putExtra("playing", false); + break; + case PAUSED: + avrcpIntent.putExtra("playing", false); + break; + case COMPLETED: + avrcpIntent.putExtra("playing", false); + break; + default: + return; + } + + context.sendBroadcast(avrcpIntent); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/receiver/AudioNoisyReceiver.java b/app/src/main/java/github/daneren2005/dsub/receiver/AudioNoisyReceiver.java new file mode 100644 index 00000000..cb2a433e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/receiver/AudioNoisyReceiver.java @@ -0,0 +1,51 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.media.AudioManager; +import android.util.Log; + +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +public class AudioNoisyReceiver extends BroadcastReceiver { + private static final String TAG = AudioNoisyReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + DownloadService downloadService = DownloadService.getInstance(); + // Don't do anything if downloadService is not started + if(downloadService == null) { + return; + } + + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals (intent.getAction ())) { + if(!downloadService.isRemoteEnabled() && (downloadService.getPlayerState() == PlayerState.STARTED || downloadService.getPlayerState() == PlayerState.PAUSED_TEMP)) { + SharedPreferences prefs = Util.getPreferences(downloadService); + int pausePref = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_PAUSE_DISCONNECT, "0")); + if(pausePref == 0) { + downloadService.pause(); + } + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/receiver/BootReceiver.java b/app/src/main/java/github/daneren2005/dsub/receiver/BootReceiver.java new file mode 100644 index 00000000..634aeeee --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/receiver/BootReceiver.java @@ -0,0 +1,34 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import github.daneren2005.dsub.service.HeadphoneListenerService; +import github.daneren2005.dsub.util.Util; + +public class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if(Util.shouldStartOnHeadphones(context)) { + Intent serviceIntent = new Intent(); + serviceIntent.setClassName(context.getPackageName(), HeadphoneListenerService.class.getName()); + context.startService(serviceIntent); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/receiver/HeadphonePlugReceiver.java b/app/src/main/java/github/daneren2005/dsub/receiver/HeadphonePlugReceiver.java new file mode 100644 index 00000000..77948c41 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/receiver/HeadphonePlugReceiver.java @@ -0,0 +1,42 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.Util; + +public class HeadphonePlugReceiver extends BroadcastReceiver { + private static final String TAG = HeadphonePlugReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + if(Intent.ACTION_HEADSET_PLUG.equals(intent.getAction())) { + int headphoneState = intent.getIntExtra("state", -1); + Log.d(TAG, "State: " + headphoneState); + if(headphoneState == 1 && Util.shouldStartOnHeadphones(context)) { + Log.d(TAG, "Fired play event"); + Intent start = new Intent(context, DownloadService.class); + start.setAction(DownloadService.START_PLAY); + context.startService(start); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/receiver/MediaButtonIntentReceiver.java b/app/src/main/java/github/daneren2005/dsub/receiver/MediaButtonIntentReceiver.java new file mode 100644 index 00000000..8119ef2d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/receiver/MediaButtonIntentReceiver.java @@ -0,0 +1,57 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.view.KeyEvent; + +import github.daneren2005.dsub.service.DownloadService; + +/** + * @author Sindre Mehus + */ +public class MediaButtonIntentReceiver extends BroadcastReceiver { + + private static final String TAG = MediaButtonIntentReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); + if(DownloadService.getInstance() == null && (event.getKeyCode() == KeyEvent.KEYCODE_MEDIA_STOP || + event.getKeyCode() == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || event.getKeyCode() == KeyEvent.KEYCODE_HEADSETHOOK)) { + Log.w(TAG, "Ignore keycode event because downloadService is off"); + return; + } + Log.i(TAG, "Got MEDIA_BUTTON key event: " + event); + + Intent serviceIntent = new Intent(context, DownloadService.class); + serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); + context.startService(serviceIntent); + if (isOrderedBroadcast()) { + try { + abortBroadcast(); + } catch (Exception x) { + // Ignored. + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/receiver/PlayActionReceiver.java b/app/src/main/java/github/daneren2005/dsub/receiver/PlayActionReceiver.java new file mode 100644 index 00000000..2c04d829 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/receiver/PlayActionReceiver.java @@ -0,0 +1,46 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.Constants; + +public class PlayActionReceiver extends BroadcastReceiver { + private static final String TAG = PlayActionReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + if(intent.hasExtra(Constants.TASKER_EXTRA_BUNDLE)) { + Bundle data = intent.getBundleExtra(Constants.TASKER_EXTRA_BUNDLE); + Boolean startShuffled = data.getBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE); + + Intent start = new Intent(context, DownloadService.class); + start.setAction(DownloadService.START_PLAY); + start.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, startShuffled); + start.putExtra(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, data.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR)); + start.putExtra(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, data.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR)); + start.putExtra(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, data.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE)); + start.putExtra(Constants.PREFERENCES_KEY_OFFLINE, data.getInt(Constants.PREFERENCES_KEY_OFFLINE)); + context.startService(start); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/CachedMusicService.java b/app/src/main/java/github/daneren2005/dsub/service/CachedMusicService.java new file mode 100644 index 00000000..61d6205a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/CachedMusicService.java @@ -0,0 +1,1424 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.concurrent.TimeUnit; + +import org.apache.http.HttpResponse; + +import android.content.Context; +import android.graphics.Bitmap; + +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.ChatMessage; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.domain.RemoteStatus; +import github.daneren2005.dsub.domain.Lyrics; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.domain.SearchCritera; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.TimeLimitedCache; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; + +import static github.daneren2005.dsub.domain.MusicDirectory.Entry; + +/** + * @author Sindre Mehus + */ +public class CachedMusicService implements MusicService { + private static final String TAG = CachedMusicService.class.getSimpleName(); + + private static final int MUSIC_DIR_CACHE_SIZE = 20; + private static final int TTL_MUSIC_DIR = 5 * 60; // Five minutes + + private final RESTMusicService musicService; + private final TimeLimitedCache cachedLicenseValid = new TimeLimitedCache(120, TimeUnit.SECONDS); + private final TimeLimitedCache cachedIndexes = new TimeLimitedCache(60 * 60, TimeUnit.SECONDS); + private final TimeLimitedCache> cachedPlaylists = new TimeLimitedCache>(3600, TimeUnit.SECONDS); + private final TimeLimitedCache> cachedMusicFolders = new TimeLimitedCache>(10 * 3600, TimeUnit.SECONDS); + private final TimeLimitedCache> cachedPodcastChannels = new TimeLimitedCache>(10 * 3600, TimeUnit.SECONDS); + private String restUrl; + private String musicFolderId; + private boolean isTagBrowsing = false; + + public CachedMusicService(RESTMusicService musicService) { + this.musicService = musicService; + } + + @Override + public void ping(Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + musicService.ping(context, progressListener); + } + + @Override + public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + Boolean result = cachedLicenseValid.get(); + if (result == null) { + result = FileUtil.deserialize(context, getCacheName(context, "license"), Boolean.class); + + if(result == null) { + result = musicService.isLicenseValid(context, progressListener); + + // Only save a copy license is valid + if(result) { + FileUtil.serialize(context, (Boolean) result, getCacheName(context, "license")); + } + } + cachedLicenseValid.set(result, result ? 30L * 60L : 2L * 60L, TimeUnit.SECONDS); + } + return result; + } + + @Override + public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + if (refresh) { + cachedMusicFolders.clear(); + } + List result = cachedMusicFolders.get(); + if (result == null) { + if(!refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "musicFolders"), ArrayList.class); + } + + if(result == null) { + result = musicService.getMusicFolders(refresh, context, progressListener); + FileUtil.serialize(context, new ArrayList(result), getCacheName(context, "musicFolders")); + } + cachedMusicFolders.set(result); + } + return result; + } + + @Override + public void startRescan(Context context, ProgressListener listener) throws Exception { + musicService.startRescan(context, listener); + } + + @Override + public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + if (refresh) { + cachedIndexes.clear(); + cachedMusicFolders.clear(); + } + Indexes result = cachedIndexes.get(); + if (result == null) { + String name = Util.isTagBrowsing(context, musicService.getInstance(context)) ? "artists" : "indexes"; + name = getCacheName(context, name, musicFolderId); + if(!refresh) { + result = FileUtil.deserialize(context, name, Indexes.class); + } + + if(result == null) { + result = musicService.getIndexes(musicFolderId, refresh, context, progressListener); + FileUtil.serialize(context, result, name); + } + cachedIndexes.set(result); + } + return result; + } + + @Override + public MusicDirectory getMusicDirectory(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + MusicDirectory dir = null; + MusicDirectory cached = FileUtil.deserialize(context, getCacheName(context, "directory", id), MusicDirectory.class); + if(!refresh) { + dir = cached; + } + + if(dir == null) { + dir = musicService.getMusicDirectory(id, name, refresh, context, progressListener); + FileUtil.serialize(context, dir, getCacheName(context, "directory", id)); + + // If a cached copy exists to check against, look for removes + deleteRemovedEntries(context, dir, cached); + } + + return dir; + } + + @Override + public MusicDirectory getArtist(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + MusicDirectory dir = null; + MusicDirectory cached = FileUtil.deserialize(context, getCacheName(context, "artist", id), MusicDirectory.class); + if(!refresh) { + dir = cached; + } + + if(dir == null) { + dir = musicService.getArtist(id, name, refresh, context, progressListener); + FileUtil.serialize(context, dir, getCacheName(context, "artist", id)); + + // If a cached copy exists to check against, look for removes + deleteRemovedEntries(context, dir, cached); + } + + return dir; + } + + @Override + public MusicDirectory getAlbum(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + MusicDirectory dir = null; + MusicDirectory cached = FileUtil.deserialize(context, getCacheName(context, "album", id), MusicDirectory.class); + if(!refresh) { + dir = cached; + } + + if(dir == null) { + dir = musicService.getAlbum(id, name, refresh, context, progressListener); + FileUtil.serialize(context, dir, getCacheName(context, "album", id)); + + // If a cached copy exists to check against, look for removes + deleteRemovedEntries(context, dir, cached); + } + + return dir; + } + + @Override + public SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception { + return musicService.search(criteria, context, progressListener); + } + + @Override + public MusicDirectory getPlaylist(boolean refresh, String id, String name, Context context, ProgressListener progressListener) throws Exception { + MusicDirectory dir = null; + MusicDirectory cachedPlaylist = FileUtil.deserialize(context, getCacheName(context, "playlist", id), MusicDirectory.class); + if(!refresh) { + dir = cachedPlaylist; + } + if(dir == null) { + dir = musicService.getPlaylist(refresh, id, name, context, progressListener); + FileUtil.serialize(context, dir, getCacheName(context, "playlist", id)); + + File playlistFile = FileUtil.getPlaylistFile(context, Util.getServerName(context, musicService.getInstance(context)), dir.getName()); + if(cachedPlaylist == null || !playlistFile.exists() || !cachedPlaylist.getChildren().equals(dir.getChildren())) { + FileUtil.writePlaylistFile(context, playlistFile, dir); + } + } + return dir; + } + + @Override + public List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + List result = refresh ? null : cachedPlaylists.get(); + if (result == null) { + if(!refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "playlist"), ArrayList.class); + } + + if(result == null) { + result = musicService.getPlaylists(refresh, context, progressListener); + FileUtil.serialize(context, new ArrayList(result), getCacheName(context, "playlist")); + } + cachedPlaylists.set(result); + } + return result; + } + + @Override + public void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception { + cachedPlaylists.clear(); + Util.delete(new File(context.getCacheDir(), getCacheName(context, "playlist"))); + musicService.createPlaylist(id, name, entries, context, progressListener); + } + + @Override + public void deletePlaylist(String id, Context context, ProgressListener progressListener) throws Exception { + musicService.deletePlaylist(id, context, progressListener); + + new PlaylistUpdater(context, id) { + @Override + public void updateResult(List objects, Playlist result) { + objects.remove(result); + cachedPlaylists.set(objects); + } + }.execute(); + } + + @Override + public void addToPlaylist(String id, final List toAdd, Context context, ProgressListener progressListener) throws Exception { + musicService.addToPlaylist(id, toAdd, context, progressListener); + + new MusicDirectoryUpdater(context, "playlist", id) { + @Override + public boolean checkResult(Entry check) { + return true; + } + + @Override + public void updateResult(List objects, Entry result) { + objects.addAll(toAdd); + } + }.execute(); + } + + @Override + public void removeFromPlaylist(String id, final List toRemove, Context context, ProgressListener progressListener) throws Exception { + musicService.removeFromPlaylist(id, toRemove, context, progressListener); + + new MusicDirectoryUpdater(context, "playlist", id) { + @Override + public boolean checkResult(Entry check) { + return true; + } + + @Override + public void updateResult(List objects, Entry result) { + // Remove in reverse order so indexes are still correct as we iterate through + for(ListIterator iterator = toRemove.listIterator(toRemove.size()); iterator.hasPrevious(); ) { + objects.remove((int) iterator.previous()); + } + } + }.execute(); + } + + @Override + public void overwritePlaylist(String id, String name, int toRemove, final List toAdd, Context context, ProgressListener progressListener) throws Exception { + musicService.overwritePlaylist(id, name, toRemove, toAdd, context, progressListener); + + new MusicDirectoryUpdater(context, "playlist", id) { + @Override + public boolean checkResult(Entry check) { + return true; + } + + @Override + public void updateResult(List objects, Entry result) { + objects.clear(); + objects.addAll(toAdd); + } + }.execute(); + } + + @Override + public void updatePlaylist(String id, final String name, final String comment, final boolean pub, Context context, ProgressListener progressListener) throws Exception { + musicService.updatePlaylist(id, name, comment, pub, context, progressListener); + + new PlaylistUpdater(context, id) { + @Override + public void updateResult(List objects, Playlist result) { + result.setName(name); + result.setComment(comment); + result.setPublic(pub); + + cachedPlaylists.set(objects); + } + }.execute(); + } + + @Override + public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception { + return musicService.getLyrics(artist, title, context, progressListener); + } + + @Override + public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception { + musicService.scrobble(id, submission, context, progressListener); + } + + @Override + public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + try { + MusicDirectory dir = musicService.getAlbumList(type, size, offset, context, progressListener); + + // Do some serialization updates for changes to recently added + if ("newest".equals(type) && offset == 0) { + String recentlyAddedFile = getCacheName(context, type); + ArrayList recents = FileUtil.deserialize(context, recentlyAddedFile, ArrayList.class); + if (recents == null) { + recents = new ArrayList(); + } + + // Add any new items + final int instance = musicService.getInstance(context); + isTagBrowsing = Util.isTagBrowsing(context, instance); + for (final Entry album : dir.getChildren()) { + if (!recents.contains(album.getId())) { + recents.add(album.getId()); + + String cacheName, parent; + if (isTagBrowsing) { + cacheName = "artist"; + parent = album.getArtistId(); + } else { + cacheName = "directory"; + parent = album.getParent(); + } + + // Add album to artist + if (parent != null) { + new MusicDirectoryUpdater(context, cacheName, parent) { + private boolean changed = false; + + @Override + public boolean checkResult(Entry check) { + return true; + } + + @Override + public void updateResult(List objects, Entry result) { + // Only add if it doesn't already exist in it! + if (!objects.contains(album)) { + objects.add(album); + changed = true; + } + } + + @Override + public void save(ArrayList objects) { + // Only save if actually added to artist + if (changed) { + musicDirectory.replaceChildren(objects); + // Reapply sort after addition + musicDirectory.sortChildren(context, instance); + FileUtil.serialize(context, musicDirectory, cacheName); + } + } + }.execute(); + } else { + // If parent is null, then this is a root level album + final Artist artist = new Artist(); + artist.setId(album.getId()); + artist.setName(album.getTitle()); + + new IndexesUpdater(context, isTagBrowsing ? "artists" : "indexes") { + private boolean changed = false; + + @Override + public boolean checkResult(Artist check) { + return true; + } + + @Override + public void updateResult(List objects, Artist result) { + if (!objects.contains(artist)) { + objects.add(artist); + changed = true; + } + } + + @Override + public void save(ArrayList objects) { + if (changed) { + indexes.setArtists(objects); + // Reapply sort after addition + indexes.sortChildren(context); + FileUtil.serialize(context, indexes, cacheName); + cachedIndexes.set(indexes); + } + } + }.execute(); + } + } + } + + // Keep list from growing into infinity + while (recents.size() > 0) { + recents.remove(0); + } + FileUtil.serialize(context, recents, recentlyAddedFile); + } + + FileUtil.serialize(context, dir, getCacheName(context, type, Integer.toString(offset))); + return dir; + } catch(IOException e) { + MusicDirectory dir = FileUtil.deserialize(context, getCacheName(context, type, Integer.toString(offset)), MusicDirectory.class); + + if(dir == null) { + // If we are at start and no cache, throw error higher + if(offset == 0) { + throw e; + } else { + // Otherwise just pretend we are at the end of the list + return new MusicDirectory(); + } + } else { + return dir; + } + } + } + + @Override + public MusicDirectory getAlbumList(String type, String extra, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + try { + MusicDirectory dir = musicService.getAlbumList(type, extra, size, offset, context, progressListener); + FileUtil.serialize(context, dir, getCacheName(context, type + extra, Integer.toString(offset))); + return dir; + } catch(IOException e) { + MusicDirectory dir = FileUtil.deserialize(context, getCacheName(context, type + extra, Integer.toString(offset)), MusicDirectory.class); + + if(dir == null) { + // If we are at start and no cache, throw error higher + if(offset == 0) { + throw e; + } else { + // Otherwise just pretend we are at the end of the list + return new MusicDirectory(); + } + } else { + return dir; + } + } + } + + @Override + public MusicDirectory getRandomSongs(int size, String artistId, Context context, ProgressListener progressListener) throws Exception { + return musicService.getRandomSongs(size, artistId, context, progressListener); + } + + @Override + public MusicDirectory getStarredList(Context context, ProgressListener progressListener) throws Exception { + try { + MusicDirectory dir = musicService.getStarredList(context, progressListener); + + MusicDirectory oldDir = FileUtil.deserialize(context, "starred", MusicDirectory.class); + if (oldDir != null) { + final List newList = new ArrayList(); + newList.addAll(dir.getChildren()); + final List oldList = oldDir.getChildren(); + + for (Iterator it = oldList.iterator(); it.hasNext(); ) { + Entry oldEntry = it.next(); + + // Remove entries from newList + if (newList.remove(oldEntry)) { + // If it was removed, then remove it from old list as well + it.remove(); + } else { + oldEntry.setStarred(false); + } + } + + List totalList = new ArrayList(); + totalList.addAll(oldList); + totalList.addAll(newList); + + new StarUpdater(context, totalList).execute(); + } + FileUtil.serialize(context, dir, "starred"); + + return dir; + } catch(IOException e) { + MusicDirectory dir = FileUtil.deserialize(context, "starred", MusicDirectory.class); + if(dir == null) { + throw e; + } else { + return dir; + } + } + } + + @Override + public MusicDirectory getRandomSongs(int size, String folder, String genre, String startYear, String endYear, Context context, ProgressListener progressListener) throws Exception { + return musicService.getRandomSongs(size, folder, genre, startYear, endYear, context, progressListener); + } + + @Override + public String getCoverArtUrl(Context context, Entry entry) throws Exception { + return musicService.getCoverArtUrl(context, entry); + } + + @Override + public Bitmap getCoverArt(Context context, Entry entry, int size, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + return musicService.getCoverArt(context, entry, size, progressListener, task); + } + + @Override + public HttpResponse getDownloadInputStream(Context context, Entry song, long offset, int maxBitrate, SilentBackgroundTask task) throws Exception { + return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task); + } + + @Override + public String getMusicUrl(Context context, Entry song, int maxBitrate) throws Exception { + return musicService.getMusicUrl(context, song, maxBitrate); + } + + @Override + public String getVideoUrl(int maxBitrate, Context context, String id) { + return musicService.getVideoUrl(maxBitrate, context, id); + } + + @Override + public String getVideoStreamUrl(String format, int maxBitrate, Context context, String id) throws Exception { + return musicService.getVideoStreamUrl(format, maxBitrate, context, id); + } + + @Override + public String getHlsUrl(String id, int bitRate, Context context) throws Exception { + return musicService.getHlsUrl(id, bitRate, context); + } + + @Override + public RemoteStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception { + return musicService.updateJukeboxPlaylist(ids, context, progressListener); + } + + @Override + public RemoteStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception { + return musicService.skipJukebox(index, offsetSeconds, context, progressListener); + } + + @Override + public RemoteStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception { + return musicService.stopJukebox(context, progressListener); + } + + @Override + public RemoteStatus startJukebox(Context context, ProgressListener progressListener) throws Exception { + return musicService.startJukebox(context, progressListener); + } + + @Override + public RemoteStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception { + return musicService.getJukeboxStatus(context, progressListener); + } + + @Override + public RemoteStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception { + return musicService.setJukeboxGain(gain, context, progressListener); + } + + @Override + public void setStarred(List entries, List artists, List albums, final boolean starred, ProgressListener progressListener, Context context) throws Exception { + musicService.setStarred(entries, artists, albums, starred, progressListener, context); + + // Fuzzy logic to update parents serialization + List allEntries = new ArrayList(); + if(artists != null) { + allEntries.addAll(artists); + } + if(albums != null) { + allEntries.addAll(albums); + } + if (entries != null) { + allEntries.addAll(entries); + } + + new StarUpdater(context, allEntries).execute(); + } + + @Override + public List getShares(Context context, ProgressListener progressListener) throws Exception { + return musicService.getShares(context, progressListener); + } + + @Override + public List createShare(List ids, String description, Long expires, Context context, ProgressListener progressListener) throws Exception { + return musicService.createShare(ids, description, expires, context, progressListener); + } + + @Override + public void deleteShare(String id, Context context, ProgressListener progressListener) throws Exception { + musicService.deleteShare(id, context, progressListener); + } + + @Override + public void updateShare(String id, String description, Long expires, Context context, ProgressListener progressListener) throws Exception { + musicService.updateShare(id, description, expires, context, progressListener); + } + + @Override + public List getChatMessages(Long since, Context context, ProgressListener progressListener) throws Exception { + return musicService.getChatMessages(since, context, progressListener); + } + + @Override + public void addChatMessage(String message, Context context, ProgressListener progressListener) throws Exception { + musicService.addChatMessage(message, context, progressListener); + } + + @Override + public List getGenres(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List result = null; + + if(!refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "genre"), ArrayList.class); + } + + if(result == null) { + result = musicService.getGenres(refresh, context, progressListener); + FileUtil.serialize(context, new ArrayList(result), getCacheName(context, "genre")); + } + + return result; + } + + @Override + public MusicDirectory getSongsByGenre(String genre, int count, int offset, Context context, ProgressListener progressListener) throws Exception { + try { + MusicDirectory dir = musicService.getSongsByGenre(genre, count, offset, context, progressListener); + FileUtil.serialize(context, dir, getCacheName(context, "genreSongs", Integer.toString(offset))); + + return dir; + } catch(IOException e) { + MusicDirectory dir = FileUtil.deserialize(context, getCacheName(context, "genreSongs", Integer.toString(offset)), MusicDirectory.class); + + if(dir == null) { + // If we are at start and no cache, throw error higher + if(offset == 0) { + throw e; + } else { + // Otherwise just pretend we are at the end of the list + return new MusicDirectory(); + } + } else { + return dir; + } + } + } + + @Override + public MusicDirectory getTopTrackSongs(String artist, int size, Context context, ProgressListener progressListener) throws Exception { + return musicService.getTopTrackSongs(artist, size, context, progressListener); + } + + @Override + public List getPodcastChannels(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + List result = refresh ? null : cachedPodcastChannels.get(); + + if (result == null) { + if(!refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "podcast"), ArrayList.class); + } + + if(result == null) { + result = musicService.getPodcastChannels(refresh, context, progressListener); + FileUtil.serialize(context, new ArrayList(result), getCacheName(context, "podcast")); + } + cachedPodcastChannels.set(result); + } + + return result; + } + + @Override + public MusicDirectory getPodcastEpisodes(boolean refresh, String id, Context context, ProgressListener progressListener) throws Exception { + String altId = "p-" + id; + MusicDirectory result = null; + + if(!refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "directory", altId), MusicDirectory.class, 10); + } + + if(result == null) { + result = musicService.getPodcastEpisodes(refresh, id, context, progressListener); + FileUtil.serialize(context, result, getCacheName(context, "directory", altId)); + } + + return result; + } + + @Override + public void refreshPodcasts(Context context, ProgressListener progressListener) throws Exception { + musicService.refreshPodcasts(context, progressListener); + } + + @Override + public void createPodcastChannel(String url, Context context, ProgressListener progressListener) throws Exception{ + musicService.createPodcastChannel(url, context, progressListener); + } + + @Override + public void deletePodcastChannel(final String id, Context context, ProgressListener progressListener) throws Exception{ + new SerializeUpdater(context, "podcast") { + @Override + public boolean checkResult(PodcastChannel check) { + return id.equals(check.getId()); + } + + @Override + public void updateResult(List objects, PodcastChannel result) { + objects.remove(result); + cachedPodcastChannels.set(objects); + } + }.execute(); + musicService.deletePodcastChannel(id, context, progressListener); + } + + @Override + public void downloadPodcastEpisode(String id, Context context, ProgressListener progressListener) throws Exception{ + musicService.downloadPodcastEpisode(id, context, progressListener); + } + + @Override + public void deletePodcastEpisode(final String id, String parent, ProgressListener progressListener, Context context) throws Exception{ + musicService.deletePodcastEpisode(id, parent, progressListener, context); + + new MusicDirectoryUpdater(context, "directory", "p-" + parent) { + @Override + public boolean checkResult(Entry check) { + return id.equals(((PodcastEpisode) check).getEpisodeId()); + } + + @Override + public void updateResult(List objects, Entry result) { + objects.remove(result); + } + }.execute(); + } + + @Override + public void setRating(final Entry entry, final int rating, Context context, ProgressListener progressListener) throws Exception { + musicService.setRating(entry, rating, context, progressListener); + + new GenericEntryUpdater(context, entry) { + @Override + public void updateResult(Entry result) { + result.setRating(rating); + } + }.execute(); + } + + @Override + public MusicDirectory getBookmarks(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + MusicDirectory bookmarks = musicService.getBookmarks(refresh, context, progressListener); + + MusicDirectory oldBookmarks = FileUtil.deserialize(context, "bookmarks", MusicDirectory.class); + if(oldBookmarks != null) { + final List oldList = oldBookmarks.getChildren(); + final List newList = new ArrayList(); + newList.addAll(bookmarks.getChildren()); + + for(Iterator it = oldList.iterator(); it.hasNext(); ) { + Entry oldEntry = it.next(); + // Remove entries from newList + int position = newList.indexOf(oldEntry); + if(position != -1) { + Entry newEntry = newList.get(position); + if(newEntry.getBookmark().getPosition() == oldEntry.getBookmark().getPosition()) { + newList.remove(position); + } + + // Remove from old regardless of whether position is wrong + it.remove(); + } else { + oldEntry.setBookmark(null); + } + } + + List totalList = new ArrayList(); + totalList.addAll(oldList); + totalList.addAll(newList); + + new BookmarkUpdater(context, totalList).execute(); + } + FileUtil.serialize(context, bookmarks, "bookmarks"); + + return bookmarks; + } + + @Override + public void createBookmark(Entry entry, int position, String comment, Context context, ProgressListener progressListener) throws Exception { + musicService.createBookmark(entry, position, comment, context, progressListener); + + new BookmarkUpdater(context, entry).execute(); + } + + @Override + public void deleteBookmark(Entry entry, Context context, ProgressListener progressListener) throws Exception { + musicService.deleteBookmark(entry, context, progressListener); + + new BookmarkUpdater(context, entry).execute(); + } + + @Override + public User getUser(boolean refresh, String username, Context context, ProgressListener progressListener) throws Exception { + User result = null; + + try { + result = musicService.getUser(refresh, username, context, progressListener); + FileUtil.serialize(context, result, getCacheName(context, "user-" + username)); + } catch(Exception e) { + // Don't care + } + + if(result == null && !refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "user-" + username), User.class); + } + + return result; + } + + @Override + public List getUsers(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List result = null; + + if(!refresh) { + result = FileUtil.deserialize(context, getCacheName(context, "users"), ArrayList.class); + } + + if(result == null) { + result = musicService.getUsers(refresh, context, progressListener); + FileUtil.serialize(context, new ArrayList(result), getCacheName(context, "users")); + } + + return result; + } + + @Override + public void createUser(final User user, Context context, ProgressListener progressListener) throws Exception { + musicService.createUser(user, context, progressListener); + + new UserUpdater(context, "") { + @Override + public boolean checkResult(User check) { + return true; + } + + @Override + public void updateResult(List users, User result) { + users.add(user); + } + }.execute(); + } + + @Override + public void updateUser(final User user, Context context, ProgressListener progressListener) throws Exception { + musicService.updateUser(user, context, progressListener); + + new UserUpdater(context, user.getUsername()) { + @Override + public void updateResult(List users, User result) { + result.setEmail(user.getEmail()); + result.setSettings(user.getSettings()); + } + }.execute(); + } + + @Override + public void deleteUser(String username, Context context, ProgressListener progressListener) throws Exception { + musicService.deleteUser(username, context, progressListener); + + new UserUpdater(context, username) { + @Override + public void updateResult(List users, User result) { + users.remove(result); + } + }.execute(); + } + + @Override + public void changeEmail(String username, final String email, Context context, ProgressListener progressListener) throws Exception { + musicService.changeEmail(username, email, context, progressListener); + + // Update cached email for user + new UserUpdater(context, username) { + @Override + public void updateResult(List users, User result) { + result.setEmail(email); + } + }.execute(); + } + + @Override + public void changePassword(String username, String password, Context context, ProgressListener progressListener) throws Exception { + musicService.changePassword(username, password, context, progressListener); + } + + @Override + public Bitmap getAvatar(String username, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + return musicService.getAvatar(username, size, context, progressListener, task); + } + + @Override + public ArtistInfo getArtistInfo(String id, boolean refresh, boolean allowNetwork, Context context, ProgressListener progressListener) throws Exception { + String cacheName = getCacheName(context, "artistInfo", id); + ArtistInfo info = null; + if(!refresh) { + info = FileUtil.deserialize(context, cacheName, ArtistInfo.class); + } + + if(info == null && allowNetwork) { + info = musicService.getArtistInfo(id, refresh, allowNetwork, context, progressListener); + FileUtil.serialize(context, info, cacheName); + } + + return info; + } + + @Override + public Bitmap getBitmap(String url, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + return musicService.getBitmap(url, size, context, progressListener, task); + } + + @Override + public MusicDirectory getVideos(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + try { + MusicDirectory dir = musicService.getVideos(refresh, context, progressListener); + FileUtil.serialize(context, dir, "videos"); + + return dir; + } catch(IOException e) { + MusicDirectory dir = FileUtil.deserialize(context, "videos", MusicDirectory.class); + if(dir == null) { + throw e; + } else { + return dir; + } + } + } + + @Override + public void savePlayQueue(List songs, Entry currentPlaying, int position, Context context, ProgressListener progressListener) throws Exception { + musicService.savePlayQueue(songs, currentPlaying, position, context, progressListener); + } + + @Override + public PlayerQueue getPlayQueue(Context context, ProgressListener progressListener) throws Exception { + return musicService.getPlayQueue(context, progressListener); + } + + @Override + public int processOfflineSyncs(final Context context, final ProgressListener progressListener) throws Exception{ + return musicService.processOfflineSyncs(context, progressListener); + } + + @Override + public void setInstance(Integer instance) throws Exception { + musicService.setInstance(instance); + } + + private String getCacheName(Context context, String name, String id) { + String s = musicService.getRestUrl(context, null, false) + id; + return name + "-" + s.hashCode() + ".ser"; + } + private String getCacheName(Context context, String name) { + String s = musicService.getRestUrl(context, null, false); + return name + "-" + s.hashCode() + ".ser"; + } + + private void deleteRemovedEntries(Context context, MusicDirectory dir, MusicDirectory cached) { + if(cached != null) { + List oldList = new ArrayList(); + oldList.addAll(cached.getChildren()); + + // Remove all current items from old list + for(Entry entry: dir.getChildren()) { + oldList.remove(entry); + } + + // Anything remaining has been removed from server + MediaStoreService store = new MediaStoreService(context); + for(Entry entry: oldList) { + File file = FileUtil.getEntryFile(context, entry); + FileUtil.recursiveDelete(file, store); + } + } + } + + private abstract class SerializeUpdater { + final Context context; + final String cacheName; + final boolean singleUpdate; + + public SerializeUpdater(Context context, String cacheName) { + this(context, cacheName, true); + } + public SerializeUpdater(Context context, String cacheName, boolean singleUpdate) { + this.context = context; + this.cacheName = getCacheName(context, cacheName); + this.singleUpdate = singleUpdate; + } + public SerializeUpdater(Context context, String cacheName, String id) { + this(context, cacheName, id, true); + } + public SerializeUpdater(Context context, String cacheName, String id, boolean singleUpdate) { + this.context = context; + this.cacheName = getCacheName(context, cacheName, id); + this.singleUpdate = singleUpdate; + } + + public ArrayList getArrayList() { + return FileUtil.deserialize(context, cacheName, ArrayList.class); + } + public abstract boolean checkResult(T check); + public abstract void updateResult(List objects, T result); + public void save(ArrayList objects) { + FileUtil.serialize(context, objects, cacheName); + } + + public void execute() { + ArrayList objects = getArrayList(); + + // Only execute if something to check against + if(objects != null) { + List results = new ArrayList(); + for(T check: objects) { + if(checkResult(check)) { + results.add(check); + if(singleUpdate) { + break; + } + } + } + + // Iterate through and update each object matched + for(T result: results) { + updateResult(objects, result); + } + + // Only reserialize if at least one match was found + if(results.size() > 0) { + save(objects); + } + } + } + } + private abstract class UserUpdater extends SerializeUpdater { + String username; + + public UserUpdater(Context context, String username) { + super(context, "users"); + this.username = username; + } + + @Override + public boolean checkResult(User check) { + return username.equals(check.getUsername()); + } + } + private abstract class PlaylistUpdater extends SerializeUpdater { + String id; + + public PlaylistUpdater(Context context, String id) { + super(context, "playlist"); + this.id = id; + } + + @Override + public boolean checkResult(Playlist check) { + return id.equals(check.getId()); + } + } + private abstract class MusicDirectoryUpdater extends SerializeUpdater { + protected MusicDirectory musicDirectory; + + public MusicDirectoryUpdater(Context context, String cacheName, String id) { + super(context, cacheName, id, true); + } + public MusicDirectoryUpdater(Context context, String cacheName, String id, boolean singleUpdate) { + super(context, cacheName, id, singleUpdate); + } + + @Override + public ArrayList getArrayList() { + musicDirectory = FileUtil.deserialize(context, cacheName, MusicDirectory.class); + if(musicDirectory != null) { + return new ArrayList(musicDirectory.getChildren()); + } else { + return null; + } + } + public void save(ArrayList objects) { + musicDirectory.replaceChildren(objects); + FileUtil.serialize(context, musicDirectory, cacheName); + } + } + private abstract class PlaylistDirectoryUpdater { + Context context; + + public PlaylistDirectoryUpdater(Context context) { + this.context = context; + } + + public abstract boolean checkResult(Entry check); + public abstract void updateResult(Entry result); + + public void execute() { + List playlists = FileUtil.deserialize(context, getCacheName(context, "playlist"), ArrayList.class); + if(playlists == null) { + // No playlist list cache, nothing to update! + return; + } + + for(Playlist playlist: playlists) { + new MusicDirectoryUpdater(context, "playlist", playlist.getId(), false) { + @Override + public boolean checkResult(Entry check) { + return PlaylistDirectoryUpdater.this.checkResult(check); + } + + @Override + public void updateResult(List objects, Entry result) { + PlaylistDirectoryUpdater.this.updateResult(result); + } + }.execute(); + } + } + } + private abstract class GenericEntryUpdater { + Context context; + List entries; + + public GenericEntryUpdater(Context context, Entry entry) { + this.context = context; + this.entries = Arrays.asList(entry); + } + public GenericEntryUpdater(Context context, List entries) { + this.context = context; + this.entries = entries; + } + + public boolean checkResult(Entry entry, Entry check) { + return entry.getId().equals(check.getId()); + } + public abstract void updateResult(Entry result); + + public void execute() { + String cacheName, parent; + // Make sure it is up to date + isTagBrowsing = Util.isTagBrowsing(context, musicService.getInstance(context)); + + // Run through each entry, trying to update the directory it is in + final List songs = new ArrayList(); + for(final Entry entry: entries) { + if(isTagBrowsing) { + // If starring album, needs to reference artist instead + if(entry.isDirectory()) { + if(entry.isAlbum()) { + cacheName = "artist"; + parent = entry.getArtistId(); + } else { + cacheName = "artists"; + parent = null; + } + } else { + cacheName = "album"; + parent = entry.getAlbumId(); + } + } else { + if(entry.isDirectory() && !entry.isAlbum()) { + cacheName = "indexes"; + parent = null; + } else { + cacheName = "directory"; + parent = entry.getParent(); + } + } + + // Parent is only null when it is an artist + if(parent == null) { + new IndexesUpdater(context, cacheName) { + @Override + public boolean checkResult(Artist check) { + return GenericEntryUpdater.this.checkResult(entry, new Entry(check)); + } + + @Override + public void updateResult(List objects, Artist result) { + // Don't try to put anything here, as the Entry update method will not be called since it's a artist! + } + }.execute(); + } else { + new MusicDirectoryUpdater(context, cacheName, parent) { + @Override + public boolean checkResult(Entry check) { + return GenericEntryUpdater.this.checkResult(entry, check); + } + + @Override + public void updateResult(List objects, Entry result) { + GenericEntryUpdater.this.updateResult(result); + } + }.execute(); + } + + if(entry instanceof PodcastEpisode) { + new MusicDirectoryUpdater(context, cacheName, "p-" + entry.getParent()) { + @Override + public boolean checkResult(Entry check) { + return GenericEntryUpdater.this.checkResult(entry, check); + } + + @Override + public void updateResult(List objects, Entry result) { + GenericEntryUpdater.this.updateResult(result); + } + }.execute(); + } else if(!entry.isDirectory()) { + songs.add(entry); + } + } + + // Only run through playlists once and check each song against it + if(songs.size() > 0) { + new PlaylistDirectoryUpdater(context) { + @Override + public boolean checkResult(Entry check) { + for(Entry entry: songs) { + if(GenericEntryUpdater.this.checkResult(entry, check)) { + return true; + } + } + + return false; + } + + @Override + public void updateResult(Entry result) { + GenericEntryUpdater.this.updateResult(result); + } + }.execute(); + } + } + } + private class BookmarkUpdater extends GenericEntryUpdater { + public BookmarkUpdater(Context context, Entry entry) { + super(context, entry); + } + public BookmarkUpdater(Context context, List entries) { + super(context, entries); + } + + @Override + public boolean checkResult(Entry entry, Entry check) { + if(entry.getId().equals(check.getId())) { + int position; + if(entry.getBookmark() == null) { + position = -1; + } else { + position = entry.getBookmark().getPosition(); + } + + if(position == -1 && check.getBookmark() != null) { + check.setBookmark(null); + return true; + } else if(position >= 0 && (check.getBookmark() == null || check.getBookmark().getPosition() != position)) { + Bookmark bookmark = check.getBookmark(); + + // Create one if empty + if(bookmark == null) { + bookmark = new Bookmark(); + check.setBookmark(bookmark); + } + + // Update bookmark position no matter what + bookmark.setPosition(position); + return true; + } + } + + return false; + } + + @Override + public void updateResult(Entry result) { + + } + } + private class StarUpdater extends GenericEntryUpdater { + public StarUpdater(Context context, List entries) { + super(context, entries); + } + + @Override + public boolean checkResult(Entry entry, Entry check) { + if (entry.getId().equals(check.getId())) { + if(entry.isStarred() != check.isStarred()) { + check.setStarred(entry.isStarred()); + return true; + } + } + + return false; + } + + @Override + public void updateResult(Entry result) { + + } + }; + private abstract class IndexesUpdater extends SerializeUpdater { + Indexes indexes; + + IndexesUpdater(Context context, String name) { + super(context, name, Util.getSelectedMusicFolderId(context, musicService.getInstance(context))); + } + + @Override + public ArrayList getArrayList() { + indexes = FileUtil.deserialize(context, cacheName, Indexes.class); + if(indexes == null) { + return null; + } + + ArrayList artists = new ArrayList(); + artists.addAll(indexes.getArtists()); + artists.addAll(indexes.getShortcuts()); + return artists; + } + + public void save(ArrayList objects) { + indexes.setArtists(objects); + FileUtil.serialize(context, indexes, cacheName); + cachedIndexes.set(indexes); + } + } + + private void checkSettingsChanged(Context context) { + int instance = musicService.getInstance(context); + String newUrl = musicService.getRestUrl(context, null, false); + boolean newIsTagBrowsing = Util.isTagBrowsing(context, instance); + if (!Util.equals(newUrl, restUrl) || isTagBrowsing != newIsTagBrowsing) { + cachedMusicFolders.clear(); + cachedLicenseValid.clear(); + cachedIndexes.clear(); + cachedPlaylists.clear(); + cachedPodcastChannels.clear(); + restUrl = newUrl; + isTagBrowsing = newIsTagBrowsing; + } + + String newMusicFolderId = Util.getSelectedMusicFolderId(context, instance); + if(!Util.equals(newMusicFolderId, musicFolderId)) { + cachedIndexes.clear(); + musicFolderId = newMusicFolderId; + } + } + + public RESTMusicService getMusicService() { + return musicService; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/ChromeCastController.java b/app/src/main/java/github/daneren2005/dsub/service/ChromeCastController.java new file mode 100644 index 00000000..a729ed4e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/ChromeCastController.java @@ -0,0 +1,522 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service; + +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; + +import com.google.android.gms.cast.ApplicationMetadata; +import com.google.android.gms.cast.Cast; +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.gms.cast.RemoteMediaPlayer; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.common.images.WebImage; + +import java.io.File; +import java.io.IOException; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.util.compat.CastCompat; +import github.daneren2005.serverproxy.FileProxy; +import github.daneren2005.serverproxy.ServerProxy; +import github.daneren2005.serverproxy.WebProxy; + +/** + * Created by owner on 2/9/14. + */ +public class ChromeCastController extends RemoteController { + private static final String TAG = ChromeCastController.class.getSimpleName(); + + private CastDevice castDevice; + private GoogleApiClient apiClient; + + private boolean applicationStarted = false; + private boolean waitingForReconnect = false; + private boolean error = false; + private boolean ignoreNextPaused = false; + private String sessionId; + + private ServerProxy proxy; + private String rootLocation; + private RemoteMediaPlayer mediaPlayer; + private double gain = 0.5; + + public ChromeCastController(DownloadService downloadService, CastDevice castDevice) { + this.downloadService = downloadService; + this.castDevice = castDevice; + + SharedPreferences prefs = Util.getPreferences(downloadService); + rootLocation = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + } + + @Override + public void create(boolean playing, int seconds) { + downloadService.setPlayerState(PlayerState.PREPARING); + + ConnectionCallbacks connectionCallbacks = new ConnectionCallbacks(playing, seconds); + ConnectionFailedListener connectionFailedListener = new ConnectionFailedListener(); + Cast.Listener castClientListener = new Cast.Listener() { + @Override + public void onApplicationStatusChanged() { + if (apiClient != null && apiClient.isConnected()) { + Log.i(TAG, "onApplicationStatusChanged: " + Cast.CastApi.getApplicationStatus(apiClient)); + } + } + + @Override + public void onVolumeChanged() { + if (apiClient != null && applicationStarted) { + try { + gain = Cast.CastApi.getVolume(apiClient); + } catch (Exception e) { + Log.w(TAG, "Failed to get volume"); + } + } + } + + @Override + public void onApplicationDisconnected(int errorCode) { + shutdownInternal(); + } + + }; + + Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(castDevice, castClientListener).setVerboseLoggingEnabled(true); + apiClient = new GoogleApiClient.Builder(downloadService).useDefaultAccount() + .addApi(Cast.API, apiOptionsBuilder.build()) + .addConnectionCallbacks(connectionCallbacks) + .addOnConnectionFailedListener(connectionFailedListener) + .build(); + + apiClient.connect(); + } + + @Override + public void start() { + if(error) { + error = false; + Log.w(TAG, "Attempting to restart song"); + startSong(downloadService.getCurrentPlaying(), true, 0); + return; + } + + try { + mediaPlayer.play(apiClient); + } catch(Exception e) { + Log.e(TAG, "Failed to start"); + } + } + + @Override + public void stop() { + try { + mediaPlayer.pause(apiClient); + } catch(Exception e) { + Log.e(TAG, "Failed to pause"); + } + } + + @Override + public void shutdown() { + try { + if(mediaPlayer != null && !error) { + mediaPlayer.stop(apiClient); + } + } catch(Exception e) { + Log.e(TAG, "Failed to stop mediaPlayer", e); + } + + try { + if(apiClient != null) { + Cast.CastApi.stopApplication(apiClient); + Cast.CastApi.removeMessageReceivedCallbacks(apiClient, mediaPlayer.getNamespace()); + mediaPlayer = null; + applicationStarted = false; + } + } catch(Exception e) { + Log.e(TAG, "Failed to shutdown application", e); + } + + if(apiClient != null && apiClient.isConnected()) { + apiClient.disconnect(); + } + apiClient = null; + + if(proxy != null) { + proxy.stop(); + proxy = null; + } + } + + private void shutdownInternal() { + // This will call this.shutdown() indirectly + downloadService.setRemoteEnabled(RemoteControlState.LOCAL, null); + } + + @Override + public void updatePlaylist() { + if(downloadService.getCurrentPlaying() == null) { + startSong(null, false, 0); + } + } + + @Override + public void changePosition(int seconds) { + try { + mediaPlayer.seek(apiClient, seconds * 1000L); + } catch(Exception e) { + Log.e(TAG, "FAiled to seek to " + seconds); + } + } + + @Override + public void changeTrack(int index, DownloadFile song) { + startSong(song, true, 0); + } + + @Override + public void setVolume(int volume) { + gain = volume / 10.0; + + try { + Cast.CastApi.setVolume(apiClient, gain); + } catch(Exception e) { + Log.e(TAG, "Failed to the volume"); + } + } + @Override + public void updateVolume(boolean up) { + double delta = up ? 0.1 : -0.1; + gain += delta; + gain = Math.max(gain, 0.0); + gain = Math.min(gain, 1.0); + + try { + Cast.CastApi.setVolume(apiClient, gain); + } catch(Exception e) { + Log.e(TAG, "Failed to the volume"); + } + } + @Override + public double getVolume() { + return Cast.CastApi.getVolume(apiClient); + } + + @Override + public int getRemotePosition() { + if(mediaPlayer != null) { + return (int) (mediaPlayer.getApproximateStreamPosition() / 1000L); + } else { + return 0; + } + } + + @Override + public int getRemoteDuration() { + if(mediaPlayer != null) { + return (int) (mediaPlayer.getStreamDuration() / 1000L); + } else { + return 0; + } + } + + void startSong(DownloadFile currentPlaying, boolean autoStart, int position) { + if(currentPlaying == null) { + try { + if (mediaPlayer != null && !error) { + mediaPlayer.stop(apiClient); + } + } catch(Exception e) { + // Just means it didn't need to be stopped + } + downloadService.setPlayerState(PlayerState.IDLE); + return; + } + downloadService.setPlayerState(PlayerState.PREPARING); + MusicDirectory.Entry song = currentPlaying.getSong(); + + try { + MusicService musicService = MusicServiceFactory.getMusicService(downloadService); + String url; + // Offline, use file proxy + if(Util.isOffline(downloadService) || song.getId().indexOf(rootLocation) != -1) { + if(proxy == null) { + proxy = new FileProxy(downloadService); + proxy.start(); + } + + // Offline song + if(song.getId().indexOf(rootLocation) != -1) { + url = proxy.getPublicAddress(song.getId()); + } else { + // Playing online song in offline mode + url = proxy.getPublicAddress(currentPlaying.getCompleteFile().getPath()); + } + } else { + // Check if we want a proxy going still + if(Util.isCastProxy(downloadService)) { + if(proxy instanceof FileProxy) { + proxy.stop(); + proxy = null; + } + + if(proxy == null) { + proxy = createWebProxy(); + proxy.start(); + } + } else if(proxy != null) { + proxy.stop(); + proxy = null; + } + + if(song.isVideo()) { + url = musicService.getHlsUrl(song.getId(), currentPlaying.getBitRate(), downloadService); + } else { + url = musicService.getMusicUrl(downloadService, song, currentPlaying.getBitRate()); + } + + // If proxy is going, it is a WebProxy + if(proxy != null) { + url = proxy.getPublicAddress(url); + } + } + + // Setup song/video information + MediaMetadata meta = new MediaMetadata(song.isVideo() ? MediaMetadata.MEDIA_TYPE_MOVIE : MediaMetadata.MEDIA_TYPE_MUSIC_TRACK); + meta.putString(MediaMetadata.KEY_TITLE, song.getTitle()); + if(song.getTrack() != null) { + meta.putInt(MediaMetadata.KEY_TRACK_NUMBER, song.getTrack()); + } + if(!song.isVideo()) { + meta.putString(MediaMetadata.KEY_ARTIST, song.getArtist()); + meta.putString(MediaMetadata.KEY_ALBUM_ARTIST, song.getArtist()); + meta.putString(MediaMetadata.KEY_ALBUM_TITLE, song.getAlbum()); + + String coverArt = ""; + if(proxy == null || proxy instanceof WebProxy) { + coverArt = musicService.getCoverArtUrl(downloadService, song); + + // If proxy is going, it is a web proxy + if(proxy != null) { + coverArt = proxy.getPublicAddress(coverArt); + } + + meta.addImage(new WebImage(Uri.parse(coverArt))); + } else { + File coverArtFile = FileUtil.getAlbumArtFile(downloadService, song); + if(coverArtFile != null && coverArtFile.exists()) { + coverArt = proxy.getPublicAddress(coverArtFile.getPath()); + meta.addImage(new WebImage(Uri.parse(coverArt))); + } + } + } + + String contentType; + if(song.isVideo()) { + contentType = "application/x-mpegURL"; + } + else if(song.getTranscodedContentType() != null) { + contentType = song.getTranscodedContentType(); + } else if(song.getContentType() != null) { + contentType = song.getContentType(); + } else { + contentType = "audio/mpeg"; + } + + // Load it into a MediaInfo wrapper + MediaInfo mediaInfo = new MediaInfo.Builder(url) + .setContentType(contentType) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setMetadata(meta) + .build(); + + if(autoStart) { + ignoreNextPaused = true; + } + + mediaPlayer.load(apiClient, mediaInfo, autoStart, position * 1000L).setResultCallback(new ResultCallback() { + @Override + public void onResult(RemoteMediaPlayer.MediaChannelResult result) { + if (result.getStatus().isSuccess()) { + // Handled in other handler + } else { + Log.e(TAG, "Failed to load: " + result.getStatus().toString()); + failedLoad(); + } + } + }); + } catch (IllegalStateException e) { + Log.e(TAG, "Problem occurred with media during loading", e); + failedLoad(); + } catch (Exception e) { + Log.e(TAG, "Problem opening media during loading", e); + failedLoad(); + } + } + + private void failedLoad() { + Util.toast(downloadService, downloadService.getResources().getString(R.string.download_failed_to_load)); + downloadService.setPlayerState(PlayerState.STOPPED); + error = true; + } + + + private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks { + private boolean isPlaying; + private int position; + private ResultCallback resultCallback; + + ConnectionCallbacks(boolean isPlaying, int position) { + this.isPlaying = isPlaying; + this.position = position; + + resultCallback = new ResultCallback() { + @Override + public void onResult(Cast.ApplicationConnectionResult result) { + Status status = result.getStatus(); + if (status.isSuccess()) { + ApplicationMetadata applicationMetadata = result.getApplicationMetadata(); + sessionId = result.getSessionId(); + String applicationStatus = result.getApplicationStatus(); + boolean wasLaunched = result.getWasLaunched(); + + applicationStarted = true; + setupChannel(); + } else { + shutdownInternal(); + } + } + }; + } + + @Override + public void onConnected(Bundle connectionHint) { + if (waitingForReconnect) { + Log.i(TAG, "Reconnecting"); + reconnectApplication(); + } else { + launchApplication(); + } + } + + @Override + public void onConnectionSuspended(int cause) { + Log.w(TAG, "Connection suspended"); + isPlaying = downloadService.getPlayerState() == PlayerState.STARTED; + position = getRemotePosition(); + waitingForReconnect = true; + } + + void launchApplication() { + try { + Cast.CastApi.launchApplication(apiClient, CastCompat.APPLICATION_ID, false).setResultCallback(resultCallback); + } catch (Exception e) { + Log.e(TAG, "Failed to launch application", e); + } + } + void reconnectApplication() { + try { + Cast.CastApi.joinApplication(apiClient, CastCompat.APPLICATION_ID, sessionId).setResultCallback(resultCallback); + } catch (Exception e) { + Log.e(TAG, "Failed to reconnect application", e); + } + } + void setupChannel() { + if(!waitingForReconnect) { + mediaPlayer = new RemoteMediaPlayer(); + mediaPlayer.setOnStatusUpdatedListener(new RemoteMediaPlayer.OnStatusUpdatedListener() { + @Override + public void onStatusUpdated() { + MediaStatus mediaStatus = mediaPlayer.getMediaStatus(); + if (mediaStatus == null) { + return; + } + + switch (mediaStatus.getPlayerState()) { + case MediaStatus.PLAYER_STATE_PLAYING: + if (ignoreNextPaused) { + ignoreNextPaused = false; + } + downloadService.setPlayerState(PlayerState.STARTED); + break; + case MediaStatus.PLAYER_STATE_PAUSED: + if (!ignoreNextPaused) { + downloadService.setPlayerState(PlayerState.PAUSED); + } + break; + case MediaStatus.PLAYER_STATE_BUFFERING: + downloadService.setPlayerState(PlayerState.PREPARING); + break; + case MediaStatus.PLAYER_STATE_IDLE: + if (mediaStatus.getIdleReason() == MediaStatus.IDLE_REASON_FINISHED) { + downloadService.setPlayerState(PlayerState.COMPLETED); + downloadService.postPlayCleanup(); + downloadService.onSongCompleted(); + } else if (mediaStatus.getIdleReason() == MediaStatus.IDLE_REASON_INTERRUPTED) { + if (downloadService.getPlayerState() != PlayerState.PREPARING) { + downloadService.setPlayerState(PlayerState.PREPARING); + } + } else if (mediaStatus.getIdleReason() == MediaStatus.IDLE_REASON_ERROR) { + Log.e(TAG, "Idle due to unknown error"); + downloadService.setPlayerState(PlayerState.COMPLETED); + downloadService.next(); + } else { + Log.w(TAG, "Idle reason: " + mediaStatus.getIdleReason()); + downloadService.setPlayerState(PlayerState.IDLE); + } + break; + } + } + }); + } + + try { + Cast.CastApi.setMessageReceivedCallbacks(apiClient, mediaPlayer.getNamespace(), mediaPlayer); + } catch (IOException e) { + Log.e(TAG, "Exception while creating channel", e); + } + + if(!waitingForReconnect) { + DownloadFile currentPlaying = downloadService.getCurrentPlaying(); + startSong(currentPlaying, isPlaying, position); + } + if(waitingForReconnect) { + waitingForReconnect = false; + } + } + } + + private class ConnectionFailedListener implements GoogleApiClient.OnConnectionFailedListener { + @Override + public void onConnectionFailed(ConnectionResult result) { + shutdownInternal(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/DLNAController.java b/app/src/main/java/github/daneren2005/dsub/service/DLNAController.java new file mode 100644 index 00000000..64abce8a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/DLNAController.java @@ -0,0 +1,687 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service; + +import android.content.SharedPreferences; +import android.os.Looper; +import android.util.Log; + +import org.fourthline.cling.controlpoint.ActionCallback; +import org.fourthline.cling.controlpoint.ControlPoint; +import org.fourthline.cling.controlpoint.SubscriptionCallback; +import org.fourthline.cling.model.action.ActionInvocation; +import org.fourthline.cling.model.gena.CancelReason; +import org.fourthline.cling.model.gena.GENASubscription; +import org.fourthline.cling.model.message.UpnpResponse; +import org.fourthline.cling.model.meta.Action; +import org.fourthline.cling.model.meta.Service; +import org.fourthline.cling.model.meta.StateVariable; +import org.fourthline.cling.model.state.StateVariableValue; +import org.fourthline.cling.model.types.ServiceType; +import org.fourthline.cling.model.types.UnsignedIntegerFourBytes; +import org.fourthline.cling.support.avtransport.callback.GetPositionInfo; +import org.fourthline.cling.support.avtransport.callback.Pause; +import org.fourthline.cling.support.avtransport.callback.Play; +import org.fourthline.cling.support.avtransport.callback.Seek; +import org.fourthline.cling.support.avtransport.callback.SetAVTransportURI; +import org.fourthline.cling.support.avtransport.callback.Stop; +import org.fourthline.cling.support.avtransport.lastchange.AVTransportLastChangeParser; +import org.fourthline.cling.support.avtransport.lastchange.AVTransportVariable; +import org.fourthline.cling.support.contentdirectory.DIDLParser; +import org.fourthline.cling.support.lastchange.LastChange; +import org.fourthline.cling.support.model.DIDLContent; +import org.fourthline.cling.support.model.DIDLObject; +import org.fourthline.cling.support.model.PositionInfo; +import org.fourthline.cling.support.model.Res; +import org.fourthline.cling.support.model.SeekMode; +import org.fourthline.cling.support.model.item.Item; +import org.fourthline.cling.support.model.item.MusicTrack; +import org.fourthline.cling.support.model.item.VideoItem; +import org.fourthline.cling.support.renderingcontrol.callback.SetVolume; +import org.seamless.util.MimeType; + +import java.io.File; +import java.net.URI; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.atomic.AtomicLong; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.DLNADevice; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Pair; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.serverproxy.FileProxy; +import github.daneren2005.serverproxy.ServerProxy; +import github.daneren2005.serverproxy.WebProxy; + +public class DLNAController extends RemoteController { + private static final String TAG = DLNAController.class.getSimpleName(); + private static final long SEARCH_UPDATE_INTERVAL_SECONDS = 10L * 60L * 1000L; + private static final long STATUS_UPDATE_INTERVAL_SECONDS = 3000L; + + DLNADevice device; + ControlPoint controlPoint; + SubscriptionCallback callback; + boolean supportsSeek = false; + boolean supportsSetupNext = false; + + private ServerProxy proxy; + String rootLocation = ""; + boolean error = false; + + final AtomicLong lastUpdate = new AtomicLong(); + int currentPosition = 0; + String currentPlayingURI; + String nextPlayingURI; + DownloadFile nextPlaying; + boolean running = true; + boolean hasDuration = false; + Runnable searchDLNA = new Runnable() { + @Override + public void run() { + if(controlPoint == null || !running) { + return; + } + + controlPoint.search(); + downloadService.postDelayed(searchDLNA, SEARCH_UPDATE_INTERVAL_SECONDS); + } + }; + + public DLNAController(DownloadService downloadService, ControlPoint controlPoint, DLNADevice device) { + this.downloadService = downloadService; + this.controlPoint = controlPoint; + this.device = device; + + SharedPreferences prefs = Util.getPreferences(downloadService); + rootLocation = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + nextSupported = true; + } + + @Override + public void create(final boolean playing, final int seconds) { + downloadService.setPlayerState(PlayerState.PREPARING); + + callback = new SubscriptionCallback(getTransportService(), 600) { + @Override + protected void failed(GENASubscription genaSubscription, UpnpResponse upnpResponse, Exception e, String msg) { + Log.w(TAG, "Register subscription callback failed: " + msg, e); + } + + @Override + protected void established(GENASubscription genaSubscription) { + Action seekAction = genaSubscription.getService().getAction("Seek"); + if(seekAction != null) { + StateVariable seekMode = genaSubscription.getService().getStateVariable("A_ARG_TYPE_SeekMode"); + for(String allowedValue: seekMode.getTypeDetails().getAllowedValues()) { + if("REL_TIME".equals(allowedValue)) { + supportsSeek = true; + } + } + } + Action setupNextAction = genaSubscription.getService().getAction("SetNextAVTransportURI"); + if(setupNextAction != null) { + supportsSetupNext = true; + } + + startSong(downloadService.getCurrentPlaying(), playing, seconds); + downloadService.postDelayed(searchDLNA, SEARCH_UPDATE_INTERVAL_SECONDS); + } + + @Override + protected void ended(GENASubscription genaSubscription, CancelReason cancelReason, UpnpResponse upnpResponse) { + Log.i(TAG, "Ended subscription"); + if(cancelReason != null) { + Log.i(TAG, "Cancel Reason: " + cancelReason.toString()); + } + if(upnpResponse != null) { + Log.i(TAG, "Reponse Message: " + upnpResponse.getStatusMessage()); + Log.i(TAG, "Response Details: " + upnpResponse.getResponseDetails()); + } + } + + @Override + protected void eventReceived(GENASubscription genaSubscription) { + Map m = genaSubscription.getCurrentValues(); + try { + LastChange lastChange = new LastChange(new AVTransportLastChangeParser(), m.get("LastChange").toString()); + if (lastChange.getEventedValue(0, AVTransportVariable.TransportState.class) == null) { + return; + } + + switch (lastChange.getEventedValue(0, AVTransportVariable.TransportState.class).getValue()) { + case PLAYING: + downloadService.setPlayerState(PlayerState.STARTED); + + // Try to setup next playing after playback start has been registered + if(supportsSetupNext && downloadService.getNextPlayerState() == PlayerState.IDLE) { + downloadService.setNextPlaying(); + } + break; + case PAUSED_PLAYBACK: + downloadService.setPlayerState(PlayerState.PAUSED); + break; + case STOPPED: + boolean failed = false; + for(StateVariableValue val: m.values()) { + if(val.toString().indexOf("TransportStatus val=\"ERROR_OCCURRED\"") != -1) { + Log.w(TAG, "Failed to load with event: " + val.toString()); + failed = true; + } + } + + if(failed) { + failedLoad(); + } else if(downloadService.getPlayerState() == PlayerState.STARTED) { + // Played until the end + downloadService.setPlayerState(PlayerState.COMPLETED); + downloadService.postPlayCleanup(); + downloadService.onSongCompleted(); + } else { + downloadService.setPlayerState(PlayerState.STOPPED); + } + break; + case TRANSITIONING: + downloadService.setPlayerState(PlayerState.PREPARING); + break; + case NO_MEDIA_PRESENT: + downloadService.setPlayerState(PlayerState.IDLE); + break; + default: + } + } + catch (Exception e) { + Log.w(TAG, "Failed to parse UPNP event", e); + failedLoad(); + } + } + + @Override + protected void eventsMissed(GENASubscription genaSubscription, int i) { + Log.w(TAG, "Event missed: " + i); + } + }; + controlPoint.execute(callback); + } + + @Override + public void start() { + if(error) { + Log.w(TAG, "Attempting to restart song"); + startSong(downloadService.getCurrentPlaying(), true, 0); + return; + } + + try { + controlPoint.execute(new Play(getTransportService()) { + @Override + public void success(ActionInvocation invocation) { + lastUpdate.set(System.currentTimeMillis()); + downloadService.setPlayerState(PlayerState.STARTED); + } + + @Override + public void failure(ActionInvocation actionInvocation, UpnpResponse upnpResponse, String msg) { + Log.w(TAG, "Failed to start playing: " + msg); + failedLoad(); + } + }); + } catch(Exception e) { + Log.w(TAG, "Failed to start", e); + } + } + + @Override + public void stop() { + try { + controlPoint.execute(new Pause(getTransportService()) { + @Override + public void success(ActionInvocation invocation) { + int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - lastUpdate.get()) / 1000L); + currentPosition += secondsSinceLastUpdate; + + downloadService.setPlayerState(PlayerState.PAUSED); + } + + @Override + public void failure(ActionInvocation actionInvocation, UpnpResponse upnpResponse, String msg) { + Log.w(TAG, "Failed to pause playing: " + msg); + } + }); + } catch(Exception e) { + Log.w(TAG, "Failed to stop", e); + } + } + + @Override + public void shutdown() { + try { + controlPoint.execute(new Stop(getTransportService()) { + @Override + public void failure(ActionInvocation invocation, org.fourthline.cling.model.message.UpnpResponse operation, String defaultMessage) { + Log.w(TAG, "Stop failed: " + defaultMessage); + } + }); + } catch(Exception e) { + Log.w(TAG, "Failed to shutdown", e); + } + + if(callback != null) { + callback.end(); + callback = null; + } + + if(proxy != null) { + proxy.stop(); + proxy = null; + } + + running = false; + } + + @Override + public void updatePlaylist() { + if(downloadService.getCurrentPlaying() == null) { + startSong(null, false, 0); + } + } + + @Override + public void changePosition(int seconds) { + SimpleDateFormat df = new SimpleDateFormat("HH:mm:ss"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + controlPoint.execute(new Seek(getTransportService(), SeekMode.REL_TIME, df.format(new Date(seconds * 1000))) { + @SuppressWarnings("rawtypes") + @Override + public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMessage) { + Log.w(TAG, "Seek failed: " + defaultMessage); + } + }); + } + + @Override + public void changeTrack(int index, DownloadFile song) { + startSong(song, true, 0); + } + + @Override + public void changeNextTrack(DownloadFile song) { + setupNextSong(song); + } + + @Override + public void setVolume(int volume) { + if(volume < 0) { + volume = 0; + } else if(volume > device.volumeMax) { + volume = device.volumeMax; + } + + device.volume = volume; + try { + controlPoint.execute(new SetVolume(device.renderer.findService(new ServiceType("schemas-upnp-org", "RenderingControl")), volume) { + @SuppressWarnings("rawtypes") + @Override + public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMessage) { + Log.w(TAG, "Set volume failed: " + defaultMessage); + } + }); + } catch(Exception e) { + Log.w(TAG, "Failed to set volume"); + } + } + + @Override + public void updateVolume(boolean up) { + int increment = device.volumeMax / 10; + setVolume(device.volume + (up ? increment : -increment)); + } + + @Override + public double getVolume() { + return device.volume; + } + + @Override + public int getRemotePosition() { + if(downloadService.getPlayerState() == PlayerState.STARTED) { + int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - lastUpdate.get()) / 1000L); + return currentPosition + secondsSinceLastUpdate; + } else { + return currentPosition; + } + } + + @Override + public boolean isSeekable() { + return supportsSeek && hasDuration; + } + + private void startSong(final DownloadFile currentPlaying, final boolean autoStart, final int position) { + try { + controlPoint.execute(new Stop(getTransportService()) { + @Override + public void success(ActionInvocation invocation) { + startSongRemote(currentPlaying, autoStart, position); + } + + @Override + public void failure(ActionInvocation invocation, org.fourthline.cling.model.message.UpnpResponse operation, String defaultMessage) { + Log.w(TAG, "Stop failed before startSong: " + defaultMessage); + startSongRemote(currentPlaying, autoStart, position); + } + }); + } catch(Exception e) { + Log.w(TAG, "Failed to stop before startSong", e); + startSongRemote(currentPlaying, autoStart, position); + } + } + private void startSongRemote(final DownloadFile currentPlaying, final boolean autoStart, final int position) { + if(currentPlaying == null) { + downloadService.setPlayerState(PlayerState.IDLE); + return; + } + error = false; + + downloadService.setPlayerState(PlayerState.PREPARING); + + try { + Pair songInfo = getSongInfo(currentPlaying); + + currentPlayingURI = songInfo.getFirst(); + downloadService.setNextPlayerState(PlayerState.IDLE); + controlPoint.execute(new SetAVTransportURI(getTransportService(), songInfo.getFirst(), songInfo.getSecond()) { + @Override + public void success(ActionInvocation invocation) { + if(position != 0) { + changePosition(position); + } + + if (autoStart) { + start(); + } else { + downloadService.setPlayerState(PlayerState.PAUSED); + } + + currentPosition = position; + lastUpdate.set(System.currentTimeMillis()); + getUpdatedStatus(); + } + + @Override + public void failure(ActionInvocation actionInvocation, UpnpResponse upnpResponse, String msg) { + Log.w(TAG, "Set URI failed: " + msg); + failedLoad(); + } + }); + } catch (Exception e) { + Log.w(TAG, "Failed startSong", e); + failedLoad(); + } + } + private void setupNextSong(final DownloadFile nextPlaying) { + this.nextPlaying = nextPlaying; + nextPlayingURI = null; + if(nextPlaying == null) { + downloadService.setNextPlayerState(PlayerState.IDLE); + Log.i(TAG, "Nothing to play next"); + return; + } + + downloadService.setNextPlayerState(PlayerState.PREPARING); + try { + Pair songInfo = getSongInfo(nextPlaying); + + nextPlayingURI = songInfo.getFirst(); + controlPoint.execute(new SetNextAVTransportURI(getTransportService(), songInfo.getFirst(), songInfo.getSecond()) { + @Override + public void success(ActionInvocation invocation) { + downloadService.setNextPlayerState(PlayerState.PREPARED); + } + + @Override + public void failure(ActionInvocation actionInvocation, UpnpResponse upnpResponse, String msg) { + Log.w(TAG, "Set next URI failed: " + msg); + nextPlayingURI = null; + DLNAController.this.nextPlaying = null; + downloadService.setNextPlayerState(PlayerState.IDLE); + } + }); + } catch (Exception e) { + Log.w(TAG, "Failed to setup next song", e); + nextPlayingURI = null; + this.nextPlaying = null; + downloadService.setNextPlayerState(PlayerState.IDLE); + } + } + + Pair getSongInfo(final DownloadFile downloadFile) throws Exception { + MusicDirectory.Entry song = downloadFile.getSong(); + + // Get url for entry + MusicService musicService = MusicServiceFactory.getMusicService(downloadService); + String url; + // In offline mode or playing offline song + if(Util.isOffline(downloadService) || song.getId().indexOf(rootLocation) != -1) { + if(proxy == null) { + proxy = new FileProxy(downloadService); + proxy.start(); + } + + // Offline song + if(song.getId().indexOf(rootLocation) != -1) { + url = proxy.getPublicAddress(song.getId()); + } else { + // Playing online song in offline mode + url = proxy.getPublicAddress(downloadFile.getCompleteFile().getPath()); + } + } else { + // Check if we want a proxy going still + if(Util.isCastProxy(downloadService)) { + if(proxy instanceof FileProxy) { + proxy.stop(); + proxy = null; + } + + if(proxy == null) { + proxy = createWebProxy(); + proxy.start(); + } + } else if(proxy != null) { + proxy.stop(); + proxy = null; + } + + if(song.isVideo()) { + url = musicService.getHlsUrl(song.getId(), downloadFile.getBitRate(), downloadService); + } else { + url = musicService.getMusicUrl(downloadService, song, downloadFile.getBitRate()); + } + + // If proxy is going, it is a WebProxy + if(proxy != null) { + url = proxy.getPublicAddress(url); + } + } + + // Create metadata for entry + Item track; + if(song.isVideo()) { + track = new VideoItem(song.getId(), song.getParent(), song.getTitle(), song.getArtist()); + } else { + String contentType = null; + if(song.getTranscodedContentType() != null) { + contentType = song.getTranscodedContentType(); + } else if(song.getContentType() != null) { + contentType = song.getContentType(); + } + + MimeType mimeType; + // If we can parse the content type, use it instead of hard coding + if(contentType != null && contentType.indexOf("/") != -1 && contentType.indexOf("/") != (contentType.length() - 1)) { + String[] typeParts = contentType.split("/"); + mimeType = new MimeType(typeParts[0], typeParts[1]); + } else { + mimeType = new MimeType("audio", "mpeg"); + } + + Res res = new Res(mimeType, song.getSize(), url); + + if(song.getDuration() != null) { + SimpleDateFormat df = new SimpleDateFormat("HH:mm:ss"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + res.setDuration(df.format(new Date(song.getDuration() * 1000))); + } + + MusicTrack musicTrack = new MusicTrack(song.getId(), song.getParent(), song.getTitle(), song.getArtist(), song.getAlbum(), song.getArtist(), res); + musicTrack.setOriginalTrackNumber(song.getTrack()); + + if(song.getCoverArt() != null) { + String coverArt = null; + if(proxy == null || proxy instanceof WebProxy) { + coverArt = musicService.getCoverArtUrl(downloadService, song); + + // If proxy is going, it is a web proxy + if(proxy != null) { + coverArt = proxy.getPublicAddress(coverArt); + } + } else { + File coverArtFile = FileUtil.getAlbumArtFile(downloadService, song); + if(coverArtFile != null && coverArtFile.exists()) { + coverArt = proxy.getPublicAddress(coverArtFile.getPath()); + } + } + + if(coverArt != null) { + DIDLObject.Property.UPNP.ALBUM_ART_URI albumArtUri = new DIDLObject.Property.UPNP.ALBUM_ART_URI(URI.create(coverArt)); + musicTrack.addProperty(albumArtUri); + } + } + + track = musicTrack; + } + + DIDLParser parser = new DIDLParser(); + DIDLContent didl = new DIDLContent(); + didl.addItem(track); + + String metadata = ""; + try { + metadata = parser.generate(didl); + } catch(Exception e) { + Log.w(TAG, "Metadata generation failed", e); + } + + return new Pair(url, metadata); + } + + private void failedLoad() { + downloadService.setPlayerState(PlayerState.STOPPED); + error = true; + + if(Looper.myLooper() != Looper.getMainLooper()) { + downloadService.post(new Runnable() { + @Override + public void run() { + Util.toast(downloadService, downloadService.getResources().getString(R.string.download_failed_to_load)); + } + }); + } else { + Util.toast(downloadService, downloadService.getResources().getString(R.string.download_failed_to_load)); + } + } + + private Service getTransportService() { + return device.renderer.findService(new ServiceType("schemas-upnp-org", "AVTransport")); + } + + private void getUpdatedStatus() { + // Don't care if shutdown in the meantime + if(!running) { + return; + } + + controlPoint.execute(new GetPositionInfo(getTransportService()) { + @Override + public void received(ActionInvocation actionInvocation, PositionInfo positionInfo) { + // Don't care if shutdown in the meantime + if(!running) { + return; + } + + long duration = positionInfo.getTrackDurationSeconds(); + hasDuration = duration > 0; + + lastUpdate.set(System.currentTimeMillis()); + + // Let's get the updated position + currentPosition = (int) positionInfo.getTrackElapsedSeconds(); + + if(positionInfo.getTrackURI() != null && positionInfo.getTrackURI().equals(nextPlayingURI) && downloadService.getNextPlayerState() == PlayerState.PREPARED) { + downloadService.setCurrentPlaying(nextPlaying, true); + downloadService.setPlayerState(PlayerState.STARTED); + downloadService.setNextPlaying(); + } + + downloadService.postDelayed(new Runnable() { + @Override + public void run() { + getUpdatedStatus(); + } + }, STATUS_UPDATE_INTERVAL_SECONDS); + } + + @Override + public void failure(ActionInvocation actionInvocation, UpnpResponse upnpResponse, String s) { + Log.w(TAG, "Failed to get an update"); + + downloadService.postDelayed(new Runnable() { + @Override + public void run() { + getUpdatedStatus(); + } + }, STATUS_UPDATE_INTERVAL_SECONDS); + } + }); + } + + private abstract class SetNextAVTransportURI extends ActionCallback { + public SetNextAVTransportURI(Service service, String uri) { + this(new UnsignedIntegerFourBytes(0), service, uri, null); + } + + public SetNextAVTransportURI(Service service, String uri, String metadata) { + this(new UnsignedIntegerFourBytes(0), service, uri, metadata); + } + + public SetNextAVTransportURI(UnsignedIntegerFourBytes instanceId, Service service, String uri) { + this(instanceId, service, uri, null); + } + + public SetNextAVTransportURI(UnsignedIntegerFourBytes instanceId, Service service, String uri, String metadata) { + super(new ActionInvocation(service.getAction("SetNextAVTransportURI"))); + getActionInvocation().setInput("InstanceID", instanceId); + getActionInvocation().setInput("NextURI", uri); + getActionInvocation().setInput("NextURIMetaData", metadata); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/DownloadFile.java b/app/src/main/java/github/daneren2005/dsub/service/DownloadFile.java new file mode 100644 index 00000000..505e4a6d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/DownloadFile.java @@ -0,0 +1,607 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.OutputStream; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.PowerManager; +import android.util.Log; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.util.CacheCleaner; +import github.daneren2005.serverproxy.BufferFile; + +import org.apache.http.Header; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class DownloadFile implements BufferFile { + private static final String TAG = DownloadFile.class.getSimpleName(); + private static final int MAX_FAILURES = 5; + private final Context context; + private final MusicDirectory.Entry song; + private final File partialFile; + private final File completeFile; + private final File saveFile; + + private final MediaStoreService mediaStoreService; + private DownloadTask downloadTask; + private boolean save; + private boolean failedDownload = false; + private int failed = 0; + private int bitRate; + private boolean isPlaying = false; + private boolean saveWhenDone = false; + private boolean completeWhenDone = false; + private Long contentLength = null; + private long currentSpeed = 0; + private boolean rateLimit = false; + + public DownloadFile(Context context, MusicDirectory.Entry song, boolean save) { + this.context = context; + this.song = song; + this.save = save; + saveFile = FileUtil.getSongFile(context, song); + bitRate = getActualBitrate(); + partialFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) + + ".partial." + FileUtil.getExtension(saveFile.getName())); + completeFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) + + ".complete." + FileUtil.getExtension(saveFile.getName())); + mediaStoreService = new MediaStoreService(context); + } + + public MusicDirectory.Entry getSong() { + return song; + } + + /** + * Returns the effective bit rate. + */ + public int getBitRate() { + if(!partialFile.exists()) { + bitRate = getActualBitrate(); + } + if (bitRate > 0) { + return bitRate; + } + return song.getBitRate() == null ? 160 : song.getBitRate(); + } + private int getActualBitrate() { + int br = song.isVideo() ? Util.getMaxVideoBitrate(context) : Util.getMaxBitrate(context); + if(br == 0 && song.getTranscodedSuffix() != null && "mp3".equals(song.getTranscodedSuffix().toLowerCase())) { + if(song.getBitRate() != null) { + br = Math.min(320, song.getBitRate()); + } else { + br = 320; + } + } else if(song.getSuffix() != null && (song.getTranscodedSuffix() == null || song.getSuffix().equals(song.getTranscodedSuffix()))) { + // If just downsampling, don't try to upsample (ie: 128 kpbs -> 192 kpbs) + if(song.getBitRate() != null && br > song.getBitRate()) { + br = song.getBitRate(); + } + } + + return br; + } + + public Long getContentLength() { + return contentLength; + } + + public long getCurrentSize() { + if(partialFile.exists()) { + return partialFile.length(); + } else { + File file = getCompleteFile(); + if(file.exists()) { + return file.length(); + } else { + return 0L; + } + } + } + + @Override + public long getEstimatedSize() { + if(contentLength != null) { + return contentLength; + } + + File file = getCompleteFile(); + if(file.exists()) { + return file.length(); + } else if(song.getDuration() == null) { + return 0; + } else { + int br = (getBitRate() * 1000) / 8; + int duration = song.getDuration(); + return br * duration; + } + } + + public long getBytesPerSecond() { + return currentSpeed; + } + + public synchronized void download() { + rateLimit = false; + preDownload(); + downloadTask.execute(); + } + public synchronized void downloadNow(MusicService musicService) { + rateLimit = true; + preDownload(); + downloadTask.setMusicService(musicService); + try { + downloadTask.doInBackground(); + } catch(InterruptedException e) { + // This should never be reached + } + } + private void preDownload() { + FileUtil.createDirectoryForParent(saveFile); + failedDownload = false; + if(!partialFile.exists()) { + bitRate = getActualBitrate(); + } + downloadTask = new DownloadTask(context); + } + + public synchronized void cancelDownload() { + if (downloadTask != null) { + downloadTask.cancel(); + } + } + + @Override + public File getFile() { + if (saveFile.exists()) { + return saveFile; + } else if (completeFile.exists()) { + return completeFile; + } else { + return partialFile; + } + } + + public File getCompleteFile() { + if (saveFile.exists()) { + return saveFile; + } + + if (completeFile.exists()) { + return completeFile; + } + + return saveFile; + } + + public File getPartialFile() { + return partialFile; + } + + public boolean isSaved() { + return saveFile.exists(); + } + + public synchronized boolean isCompleteFileAvailable() { + return saveFile.exists() || completeFile.exists(); + } + + @Override + public synchronized boolean isWorkDone() { + return saveFile.exists() || (completeFile.exists() && !save) || saveWhenDone || completeWhenDone; + } + + @Override + public void onStart() { + setPlaying(true); + } + + @Override + public void onStop() { + setPlaying(false); + } + + @Override + public synchronized void onResume() { + if(!isWorkDone() && !isFailedMax() && !isDownloading() && !isDownloadCancelled()) { + download(); + } + } + + public synchronized boolean isDownloading() { + return downloadTask != null && downloadTask.isRunning(); + } + + public synchronized boolean isDownloadCancelled() { + return downloadTask != null && downloadTask.isCancelled(); + } + + public boolean shouldSave() { + return save; + } + + public boolean isFailed() { + return failedDownload; + } + public boolean isFailedMax() { + return failed > MAX_FAILURES; + } + + public void delete() { + cancelDownload(); + + // Remove from mediaStore BEFORE deleting file since it calls getCompleteFile + deleteFromStore(); + + // Delete all possible versions of the file + File parent = partialFile.getParentFile(); + Util.delete(partialFile); + Util.delete(completeFile); + Util.delete(saveFile); + FileUtil.deleteEmptyDir(parent); + } + + public void unpin() { + if (saveFile.exists()) { + // Delete old store entry before renaming to pinned file + saveFile.renameTo(completeFile); + renameInStore(saveFile, completeFile); + } + } + + public boolean cleanup() { + boolean ok = true; + if (completeFile.exists() || saveFile.exists()) { + ok = Util.delete(partialFile); + } + if (saveFile.exists()) { + ok &= Util.delete(completeFile); + } + return ok; + } + + // In support of LRU caching. + public void updateModificationDate() { + updateModificationDate(saveFile); + updateModificationDate(partialFile); + updateModificationDate(completeFile); + } + + private void updateModificationDate(File file) { + if (file.exists()) { + boolean ok = file.setLastModified(System.currentTimeMillis()); + if (!ok) { + Log.w(TAG, "Failed to set last-modified date on " + file); + } + } + } + + public void setPlaying(boolean isPlaying) { + try { + if(saveWhenDone && !isPlaying) { + Util.renameFile(completeFile, saveFile); + renameInStore(completeFile, saveFile); + saveWhenDone = false; + } else if(completeWhenDone && !isPlaying) { + if(save) { + Util.renameFile(partialFile, saveFile); + saveToStore(); + } else { + Util.renameFile(partialFile, completeFile); + saveToStore(); + } + completeWhenDone = false; + } + } catch(IOException ex) { + Log.w(TAG, "Failed to rename file " + completeFile + " to " + saveFile, ex); + } + + this.isPlaying = isPlaying; + } + public void renamePartial() { + try { + Util.renameFile(partialFile, completeFile); + saveToStore(); + } catch(IOException ex) { + Log.w(TAG, "Failed to rename file " + partialFile + " to " + completeFile, ex); + } + } + public boolean getPlaying() { + return isPlaying; + } + + private void deleteFromStore() { + try { + mediaStoreService.deleteFromMediaStore(this); + } catch(Exception e) { + Log.w(TAG, "Failed to remove from store", e); + } + } + private void saveToStore() { + if(!Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_HIDE_MEDIA, false)) { + try { + mediaStoreService.saveInMediaStore(this); + } catch(Exception e) { + Log.w(TAG, "Failed to save in media store", e); + } + } + } + private void renameInStore(File start, File end) { + try { + mediaStoreService.renameInMediaStore(start, end); + } catch(Exception e) { + Log.w(TAG, "Failed to rename in store", e); + } + } + + @Override + public String toString() { + return "DownloadFile (" + song + ")"; + } + + private class DownloadTask extends SilentBackgroundTask { + private MusicService musicService; + + public DownloadTask(Context context) { + super(context); + } + + @Override + public Void doInBackground() throws InterruptedException { + InputStream in = null; + FileOutputStream out = null; + PowerManager.WakeLock wakeLock = null; + WifiManager.WifiLock wifiLock = null; + try { + + if (Util.isScreenLitOnDownload(context)) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, toString()); + wakeLock.acquire(); + } + + wifiLock = Util.createWifiLock(context, toString()); + wifiLock.acquire(); + + if (saveFile.exists()) { + Log.i(TAG, saveFile + " already exists. Skipping."); + checkDownloads(); + return null; + } + if (completeFile.exists()) { + if (save) { + if(isPlaying) { + saveWhenDone = true; + } else { + Util.renameFile(completeFile, saveFile); + renameInStore(completeFile, saveFile); + } + } else { + Log.i(TAG, completeFile + " already exists. Skipping."); + } + checkDownloads(); + return null; + } + + if(musicService == null) { + musicService = MusicServiceFactory.getMusicService(context); + } + + // Some devices seem to throw error on partial file which doesn't exist + boolean compare; + try { + compare = (bitRate == 0) || (song.getDuration() == 0) || (partialFile.length() == 0) || (bitRate * song.getDuration() * 1000 / 8) > partialFile.length(); + } catch(Exception e) { + compare = true; + } + if(compare) { + // Attempt partial HTTP GET, appending to the file if it exists. + HttpResponse response = musicService.getDownloadInputStream(context, song, partialFile.length(), bitRate, DownloadTask.this); + Header contentLengthHeader = response.getFirstHeader("Content-Length"); + if(contentLengthHeader != null) { + String contentLengthString = contentLengthHeader.getValue(); + if(contentLengthString != null) { + Log.i(TAG, "Content Length: " + contentLengthString); + contentLength = Long.parseLong(contentLengthString); + } + } + in = response.getEntity().getContent(); + boolean partial = response.getStatusLine().getStatusCode() == HttpStatus.SC_PARTIAL_CONTENT; + if (partial) { + Log.i(TAG, "Executed partial HTTP GET, skipping " + partialFile.length() + " bytes"); + } + + out = new FileOutputStream(partialFile, partial); + long n = copy(in, out); + Log.i(TAG, "Downloaded " + n + " bytes to " + partialFile); + out.flush(); + out.close(); + + if (isCancelled()) { + throw new Exception("Download of '" + song + "' was cancelled"); + } else if(partialFile.length() == 0) { + throw new Exception("Download of '" + song + "' failed. File is 0 bytes long."); + } + + downloadAndSaveCoverArt(musicService); + } + + if(isPlaying) { + completeWhenDone = true; + } else { + if(save) { + Util.renameFile(partialFile, saveFile); + } else { + Util.renameFile(partialFile, completeFile); + } + DownloadFile.this.saveToStore(); + } + + } catch(InterruptedException x) { + throw x; + } catch(FileNotFoundException x) { + Util.delete(completeFile); + Util.delete(saveFile); + if(!isCancelled()) { + failed = MAX_FAILURES + 1; + failedDownload = true; + Log.w(TAG, "Failed to download '" + song + "'.", x); + } + } catch(IOException x) { + Util.delete(completeFile); + Util.delete(saveFile); + if(!isCancelled()) { + failedDownload = true; + Log.w(TAG, "Failed to download '" + song + "'.", x); + } + } catch (Exception x) { + Util.delete(completeFile); + Util.delete(saveFile); + if (!isCancelled()) { + failed++; + failedDownload = true; + Log.w(TAG, "Failed to download '" + song + "'.", x); + } + } finally { + Util.close(in); + Util.close(out); + if (wakeLock != null) { + wakeLock.release(); + Log.i(TAG, "Released wake lock " + wakeLock); + } + if (wifiLock != null) { + wifiLock.release(); + } + } + + // Only run these if not interrupted, ie: cancelled + if(!isCancelled()) { + new CacheCleaner(context, DownloadService.getInstance()).cleanSpace(); + checkDownloads(); + } + + return null; + } + + private void checkDownloads() { + DownloadService downloadService = DownloadService.getInstance(); + if(downloadService != null) { + downloadService.checkDownloads(); + } + } + + @Override + public String toString() { + return "DownloadTask (" + song + ")"; + } + + public void setMusicService(MusicService musicService) { + this.musicService = musicService; + } + + private void downloadAndSaveCoverArt(MusicService musicService) throws Exception { + try { + if (song.getCoverArt() != null) { + // Check if album art already exists, don't want to needlessly load into memory + File albumArtFile = FileUtil.getAlbumArtFile(context, song); + if(!albumArtFile.exists()) { + musicService.getCoverArt(context, song, 0, null, null); + } + } + } catch (Exception x) { + Log.e(TAG, "Failed to get cover art.", x); + } + } + + private long copy(final InputStream in, OutputStream out) throws IOException, InterruptedException { + + // Start a thread that will close the input stream if the task is + // cancelled, thus causing the copy() method to return. + new Thread("DownloadFile_copy") { + @Override + public void run() { + while (true) { + Util.sleepQuietly(3000L); + if (isCancelled()) { + Util.close(in); + return; + } + if (!isRunning()) { + return; + } + } + } + }.start(); + + byte[] buffer = new byte[1024 * 16]; + long count = 0; + int n; + long lastLog = System.currentTimeMillis(); + long lastCount = 0; + + boolean activeLimit = rateLimit; + while (!isCancelled() && (n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + count += n; + lastCount += n; + + long now = System.currentTimeMillis(); + if (now - lastLog > 3000L) { // Only every so often. + Log.i(TAG, "Downloaded " + Util.formatBytes(count) + " of " + song); + currentSpeed = lastCount / ((now - lastLog) / 1000L); + lastLog = now; + lastCount = 0; + + // Re-establish every few seconds whether screen is on or not + if(rateLimit) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if(pm.isScreenOn()) { + activeLimit = true; + } else { + activeLimit = false; + } + } + } + + // If screen is on and rateLimit is true, stop downloading from exhausting bandwidth + if(activeLimit) { + Thread.sleep(10L); + } + } + return count; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/DownloadService.java b/app/src/main/java/github/daneren2005/dsub/service/DownloadService.java new file mode 100644 index 00000000..3f1c022c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/DownloadService.java @@ -0,0 +1,2410 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import static android.support.v7.media.MediaRouter.RouteInfo; +import static github.daneren2005.dsub.domain.PlayerState.COMPLETED; +import static github.daneren2005.dsub.domain.PlayerState.DOWNLOADING; +import static github.daneren2005.dsub.domain.PlayerState.IDLE; +import static github.daneren2005.dsub.domain.PlayerState.PAUSED; +import static github.daneren2005.dsub.domain.PlayerState.PAUSED_TEMP; +import static github.daneren2005.dsub.domain.PlayerState.PREPARED; +import static github.daneren2005.dsub.domain.PlayerState.PREPARING; +import static github.daneren2005.dsub.domain.PlayerState.STARTED; +import static github.daneren2005.dsub.domain.PlayerState.STOPPED; +import static github.daneren2005.dsub.domain.RemoteControlState.LOCAL; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.audiofx.AudioEffectsController; +import github.daneren2005.dsub.audiofx.EqualizerController; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.domain.RepeatMode; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.receiver.MediaButtonIntentReceiver; +import github.daneren2005.dsub.util.ArtistRadioBuffer; +import github.daneren2005.dsub.util.Notifications; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.MediaRouteManager; +import github.daneren2005.dsub.util.ShufflePlayBuffer; +import github.daneren2005.dsub.util.SimpleServiceBinder; +import github.daneren2005.dsub.util.Util; +import github.daneren2005.dsub.util.compat.RemoteControlClientHelper; +import github.daneren2005.dsub.util.tags.BastpUtil; +import github.daneren2005.dsub.view.UpdateView; +import github.daneren2005.serverproxy.BufferProxy; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +import android.annotation.TargetApi; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.audiofx.AudioEffect; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.support.v7.media.MediaRouteSelector; +import android.support.v7.media.MediaRouter; +import android.util.Log; +import android.support.v4.util.LruCache; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class DownloadService extends Service { + private static final String TAG = DownloadService.class.getSimpleName(); + + public static final String CMD_PLAY = "github.daneren2005.dsub.CMD_PLAY"; + public static final String CMD_TOGGLEPAUSE = "github.daneren2005.dsub.CMD_TOGGLEPAUSE"; + public static final String CMD_PAUSE = "github.daneren2005.dsub.CMD_PAUSE"; + public static final String CMD_STOP = "github.daneren2005.dsub.CMD_STOP"; + public static final String CMD_PREVIOUS = "github.daneren2005.dsub.CMD_PREVIOUS"; + public static final String CMD_NEXT = "github.daneren2005.dsub.CMD_NEXT"; + public static final String CANCEL_DOWNLOADS = "github.daneren2005.dsub.CANCEL_DOWNLOADS"; + public static final String START_PLAY = "github.daneren2005.dsub.START_PLAYING"; + public static final int FAST_FORWARD = 30000; + public static final int REWIND = 10000; + private static final double DELETE_CUTOFF = 0.84; + private static final int REQUIRED_ALBUM_MATCHES = 4; + private static final int SHUFFLE_MODE_NONE = 0; + private static final int SHUFFLE_MODE_ALL = 1; + private static final int SHUFFLE_MODE_ARTIST = 2; + + private RemoteControlClientHelper mRemoteControl; + + private final IBinder binder = new SimpleServiceBinder(this); + private Looper mediaPlayerLooper; + private MediaPlayer mediaPlayer; + private MediaPlayer nextMediaPlayer; + private int audioSessionId; + private boolean nextSetup = false; + private final List downloadList = new ArrayList(); + private final List backgroundDownloadList = new ArrayList(); + private final List toDelete = new ArrayList(); + private final Handler handler = new Handler(); + private Handler mediaPlayerHandler; + private final DownloadServiceLifecycleSupport lifecycleSupport = new DownloadServiceLifecycleSupport(this); + private ShufflePlayBuffer shufflePlayBuffer; + private ArtistRadioBuffer artistRadioBuffer; + + private final LruCache downloadFileCache = new LruCache(100); + private final List cleanupCandidates = new ArrayList(); + private final Scrobbler scrobbler = new Scrobbler(); + private RemoteController remoteController; + private DownloadFile currentPlaying; + private int currentPlayingIndex = -1; + private DownloadFile nextPlaying; + private DownloadFile currentDownloading; + private SilentBackgroundTask bufferTask; + private SilentBackgroundTask nextPlayingTask; + private PlayerState playerState = IDLE; + private PlayerState nextPlayerState = IDLE; + private boolean removePlayed; + private boolean shufflePlay; + private boolean artistRadio; + private long revision; + private static DownloadService instance; + private String suggestedPlaylistName; + private String suggestedPlaylistId; + private PowerManager.WakeLock wakeLock; + private boolean keepScreenOn; + private int cachedPosition = 0; + private boolean downloadOngoing = false; + private float volume = 1.0f; + + private AudioEffectsController effectsController; + private RemoteControlState remoteState = LOCAL; + private PositionCache positionCache; + private BufferProxy proxy; + + private Timer sleepTimer; + private int timerDuration; + private boolean autoPlayStart = false; + + private MediaRouteManager mediaRouter; + + // Variables to manage getCurrentPosition sometimes starting from an arbitrary non-zero number + private long subtractNextPosition = 0; + private int subtractPosition = 0; + + @Override + public void onCreate() { + super.onCreate(); + + final SharedPreferences prefs = Util.getPreferences(this); + new Thread(new Runnable() { + public void run() { + Looper.prepare(); + + mediaPlayer = new MediaPlayer(); + mediaPlayer.setWakeMode(DownloadService.this, PowerManager.PARTIAL_WAKE_LOCK); + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + + mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mediaPlayer, int what, int more) { + handleError(new Exception("MediaPlayer error: " + what + " (" + more + ")")); + return false; + } + }); + try { + audioSessionId = mediaPlayer.getAudioSessionId(); + } catch(Throwable e) { + // Froyo or lower + } + + try { + Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); + i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId); + i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, getPackageName()); + sendBroadcast(i); + } catch(Throwable e) { + // Froyo or lower + } + + effectsController = new AudioEffectsController(DownloadService.this, audioSessionId); + if(prefs.getBoolean(Constants.PREFERENCES_EQUALIZER_ON, false)) { + getEqualizerController(); + } + + mediaPlayerLooper = Looper.myLooper(); + mediaPlayerHandler = new Handler(mediaPlayerLooper); + Looper.loop(); + } + }, "DownloadService").start(); + + Util.registerMediaButtonEventReceiver(this); + + if (mRemoteControl == null) { + // Use the remote control APIs (if available) to set the playback state + mRemoteControl = RemoteControlClientHelper.createInstance(); + ComponentName mediaButtonReceiverComponent = new ComponentName(getPackageName(), MediaButtonIntentReceiver.class.getName()); + mRemoteControl.register(this, mediaButtonReceiverComponent); + } + + PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this.getClass().getName()); + wakeLock.setReferenceCounted(false); + + try { + timerDuration = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_SLEEP_TIMER_DURATION, "5")); + } catch(Throwable e) { + timerDuration = 5; + } + sleepTimer = null; + + keepScreenOn = prefs.getBoolean(Constants.PREFERENCES_KEY_KEEP_SCREEN_ON, false); + + mediaRouter = new MediaRouteManager(this); + + instance = this; + shufflePlayBuffer = new ShufflePlayBuffer(this); + artistRadioBuffer = new ArtistRadioBuffer(this); + lifecycleSupport.onCreate(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + lifecycleSupport.onStart(intent); + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + instance = null; + + if(currentPlaying != null) currentPlaying.setPlaying(false); + if(sleepTimer != null){ + sleepTimer.cancel(); + sleepTimer.purge(); + } + lifecycleSupport.onDestroy(); + + try { + Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); + i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId); + i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, getPackageName()); + sendBroadcast(i); + } catch(Throwable e) { + // Froyo or lower + } + + mediaPlayer.release(); + if(nextMediaPlayer != null) { + nextMediaPlayer.release(); + } + mediaPlayerLooper.quit(); + shufflePlayBuffer.shutdown(); + effectsController.release(); + if (mRemoteControl != null) { + mRemoteControl.unregister(this); + mRemoteControl = null; + } + + if(bufferTask != null) { + bufferTask.cancel(); + bufferTask = null; + } + if(nextPlayingTask != null) { + nextPlayingTask.cancel(); + nextPlayingTask = null; + } + if(remoteController != null) { + remoteController.stop(); + remoteController.shutdown(); + } + if(proxy != null) { + proxy.stop(); + proxy = null; + } + mediaRouter.destroy(); + Notifications.hidePlayingNotification(this, this, handler); + Notifications.hideDownloadingNotification(this, this, handler); + } + + public static DownloadService getInstance() { + return instance; + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + public void post(Runnable r) { + handler.post(r); + } + public void postDelayed(Runnable r, long millis) { + handler.postDelayed(r, millis); + } + + public synchronized void download(List songs, boolean save, boolean autoplay, boolean playNext, boolean shuffle) { + download(songs, save, autoplay, playNext, shuffle, 0, 0); + } + public synchronized void download(List songs, boolean save, boolean autoplay, boolean playNext, boolean shuffle, int start, int position) { + setShufflePlayEnabled(false); + setArtistRadio(null); + int offset = 1; + boolean noNetwork = !Util.isOffline(this) && !Util.isNetworkConnected(this); + boolean warnNetwork = false; + + if (songs.isEmpty()) { + return; + } + if (playNext) { + if (autoplay && getCurrentPlayingIndex() >= 0) { + offset = 0; + } + for (MusicDirectory.Entry song : songs) { + if(song != null) { + DownloadFile downloadFile = new DownloadFile(this, song, save); + addToDownloadList(downloadFile, getCurrentPlayingIndex() + offset); + if(noNetwork && !warnNetwork) { + if(!downloadFile.isCompleteFileAvailable()) { + warnNetwork = true; + } + } + offset++; + } + } + setNextPlaying(); + revision++; + } else { + int size = size(); + int index = getCurrentPlayingIndex(); + for (MusicDirectory.Entry song : songs) { + DownloadFile downloadFile = new DownloadFile(this, song, save); + addToDownloadList(downloadFile, -1); + if(noNetwork && !warnNetwork) { + if(!downloadFile.isCompleteFileAvailable()) { + warnNetwork = true; + } + } + } + if(!autoplay && (size - 1) == index) { + setNextPlaying(); + } + revision++; + } + updateRemotePlaylist(); + + if(shuffle) { + shuffle(); + } + if(warnNetwork) { + Util.toast(this, R.string.select_album_no_network); + } + + if (autoplay) { + play(start, true, position); + } else if(start != 0 || position != 0) { + play(start, false, position); + } else { + if (currentPlaying == null) { + currentPlaying = downloadList.get(0); + currentPlayingIndex = 0; + currentPlaying.setPlaying(true); + } else { + currentPlayingIndex = downloadList.indexOf(currentPlaying); + } + checkDownloads(); + } + lifecycleSupport.serializeDownloadQueue(); + } + private void addToDownloadList(DownloadFile file, int offset) { + if(offset == -1) { + downloadList.add(file); + } else { + downloadList.add(offset, file); + } + } + public synchronized void downloadBackground(List songs, boolean save) { + for (MusicDirectory.Entry song : songs) { + DownloadFile downloadFile = new DownloadFile(this, song, save); + if(!downloadFile.isWorkDone() || (downloadFile.shouldSave() && !downloadFile.isSaved())) { + // Only add to list if there is work to be done + backgroundDownloadList.add(downloadFile); + } else if(downloadFile.isSaved() && !save) { + // Quickly unpin song instead of adding it to work to be done + downloadFile.unpin(); + } + } + revision++; + + if(!Util.isOffline(this) && !Util.isNetworkConnected(this)) { + Util.toast(this, R.string.select_album_no_network); + } + + checkDownloads(); + lifecycleSupport.serializeDownloadQueue(); + } + + private void updateRemotePlaylist() { + if (remoteState != LOCAL && remoteController != null) { + remoteController.updatePlaylist(); + } + } + + public synchronized void restore(List songs, List toDelete, int currentPlayingIndex, int currentPlayingPosition) { + SharedPreferences prefs = Util.getPreferences(this); + RemoteControlState newState = RemoteControlState.values()[prefs.getInt(Constants.PREFERENCES_KEY_CONTROL_MODE, 0)]; + if(newState != LOCAL) { + String id = prefs.getString(Constants.PREFERENCES_KEY_CONTROL_ID, null); + setRemoteState(newState, null, id); + } + if(prefs.getBoolean(Constants.PREFERENCES_KEY_REMOVE_PLAYED, false)) { + removePlayed = true; + } + int startShufflePlay = prefs.getInt(Constants.PREFERENCES_KEY_SHUFFLE_MODE, SHUFFLE_MODE_NONE); + download(songs, false, false, false, false); + if(startShufflePlay != SHUFFLE_MODE_NONE) { + if(startShufflePlay == SHUFFLE_MODE_ALL) { + shufflePlay = true; + } else if(startShufflePlay == SHUFFLE_MODE_ARTIST) { + artistRadio = true; + artistRadioBuffer.restoreArtist(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_MODE_EXTRA, null)); + } + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SHUFFLE_MODE, startShufflePlay); + editor.commit(); + } + if (currentPlayingIndex != -1) { + while(mediaPlayer == null) { + Util.sleepQuietly(50L); + } + + play(currentPlayingIndex, autoPlayStart, currentPlayingPosition); + autoPlayStart = false; + } + + if(toDelete != null) { + for(MusicDirectory.Entry entry: toDelete) { + this.toDelete.add(forSong(entry)); + } + } + + suggestedPlaylistName = prefs.getString(Constants.PREFERENCES_KEY_PLAYLIST_NAME, null); + suggestedPlaylistId = prefs.getString(Constants.PREFERENCES_KEY_PLAYLIST_ID, null); + } + + public boolean isInitialized() { + return lifecycleSupport != null && lifecycleSupport.isInitialized(); + } + + public synchronized Date getLastStateChanged() { + return lifecycleSupport.getLastChange(); + } + + public synchronized void setRemovePlayed(boolean enabled) { + removePlayed = enabled; + if(removePlayed) { + checkDownloads(); + lifecycleSupport.serializeDownloadQueue(); + } + SharedPreferences.Editor editor = Util.getPreferences(this).edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_REMOVE_PLAYED, enabled); + editor.commit(); + } + public boolean isRemovePlayed() { + return removePlayed; + } + + public synchronized void setShufflePlayEnabled(boolean enabled) { + shufflePlay = enabled; + if (shufflePlay) { + clear(); + checkDownloads(); + } + SharedPreferences.Editor editor = Util.getPreferences(this).edit(); + editor.putInt(Constants.PREFERENCES_KEY_SHUFFLE_MODE, enabled ? SHUFFLE_MODE_ALL : SHUFFLE_MODE_NONE); + editor.commit(); + } + + public boolean isShufflePlayEnabled() { + return shufflePlay; + } + + public void setArtistRadio(String artistId) { + if(artistId == null) { + artistRadio = false; + } else { + artistRadio = true; + artistRadioBuffer.setArtist(artistId); + } + + SharedPreferences.Editor editor = Util.getPreferences(this).edit(); + editor.putInt(Constants.PREFERENCES_KEY_SHUFFLE_MODE, (artistId != null) ? SHUFFLE_MODE_ARTIST : SHUFFLE_MODE_NONE); + if(artistId != null) { + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_MODE_EXTRA, artistId); + } + editor.commit(); + } + + public synchronized void shuffle() { + Collections.shuffle(downloadList); + currentPlayingIndex = downloadList.indexOf(currentPlaying); + if (currentPlaying != null) { + downloadList.remove(getCurrentPlayingIndex()); + downloadList.add(0, currentPlaying); + currentPlayingIndex = 0; + } + revision++; + lifecycleSupport.serializeDownloadQueue(); + updateRemotePlaylist(); + setNextPlaying(); + } + + public RepeatMode getRepeatMode() { + return Util.getRepeatMode(this); + } + + public void setRepeatMode(RepeatMode repeatMode) { + Util.setRepeatMode(this, repeatMode); + setNextPlaying(); + } + + public boolean getKeepScreenOn() { + return keepScreenOn; + } + + public void setKeepScreenOn(boolean keepScreenOn) { + this.keepScreenOn = keepScreenOn; + + SharedPreferences prefs = Util.getPreferences(this); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_KEEP_SCREEN_ON, keepScreenOn); + editor.commit(); + } + + public synchronized DownloadFile forSong(MusicDirectory.Entry song) { + DownloadFile returnFile = null; + for (DownloadFile downloadFile : downloadList) { + if (downloadFile.getSong().equals(song)) { + if(((downloadFile.isDownloading() && !downloadFile.isDownloadCancelled() && downloadFile.getPartialFile().exists()) || downloadFile.isWorkDone())) { + // If downloading, return immediately + return downloadFile; + } else { + // Otherwise, check to make sure there isn't a background download going on first + returnFile = downloadFile; + } + } + } + for (DownloadFile downloadFile : backgroundDownloadList) { + if (downloadFile.getSong().equals(song)) { + return downloadFile; + } + } + + if(returnFile != null) { + return returnFile; + } + + DownloadFile downloadFile = downloadFileCache.get(song); + if (downloadFile == null) { + downloadFile = new DownloadFile(this, song, false); + downloadFileCache.put(song, downloadFile); + } + return downloadFile; + } + + public synchronized void clear() { + clear(true); + } + + public synchronized void clearBackground() { + if(currentDownloading != null && backgroundDownloadList.contains(currentDownloading)) { + currentDownloading.cancelDownload(); + currentDownloading = null; + } + backgroundDownloadList.clear(); + revision++; + Notifications.hideDownloadingNotification(this, this, handler); + } + + public synchronized void clearIncomplete() { + Iterator iterator = downloadList.iterator(); + while (iterator.hasNext()) { + DownloadFile downloadFile = iterator.next(); + if (!downloadFile.isCompleteFileAvailable()) { + iterator.remove(); + + // Reset if the current playing song has been removed + if(currentPlaying == downloadFile) { + reset(); + } + + currentPlayingIndex = downloadList.indexOf(currentPlaying); + } + } + lifecycleSupport.serializeDownloadQueue(); + updateRemotePlaylist(); + } + + public void setOnline(final boolean online) { + if(online) { + mediaRouter.addOnlineProviders(); + } else { + mediaRouter.removeOnlineProviders(); + } + if(shufflePlay) { + setShufflePlayEnabled(false); + } + if(artistRadio) { + setArtistRadio(null); + } + + lifecycleSupport.post(new Runnable() { + @Override + public void run() { + if (online) { + checkDownloads(); + } else { + clearIncomplete(); + } + } + }); + } + public void userSettingsChanged() { + mediaRouter.buildSelector(); + } + + public synchronized int size() { + return downloadList.size(); + } + + public synchronized void clear(boolean serialize) { + // Delete podcast if fully listened to + int position = getPlayerPosition(); + int duration = getPlayerDuration(); + boolean cutoff = isPastCutoff(position, duration, true); + if(currentPlaying != null && currentPlaying.getSong() instanceof PodcastEpisode) { + if(cutoff) { + currentPlaying.delete(); + } + } + for(DownloadFile podcast: toDelete) { + podcast.delete(); + } + toDelete.clear(); + + // Clear bookmarks from current playing if past a certain point + if(cutoff) { + clearCurrentBookmark(true); + } else { + // Check if we should be adding a new bookmark here + checkAddBookmark(); + } + if(currentPlaying != null) { + scrobbler.conditionalScrobble(this, currentPlaying, position, duration); + } + + reset(); + downloadList.clear(); + revision++; + if (currentDownloading != null && !backgroundDownloadList.contains(currentDownloading)) { + currentDownloading.cancelDownload(); + currentDownloading = null; + } + setCurrentPlaying(null, false); + + if (serialize) { + lifecycleSupport.serializeDownloadQueue(); + } + updateRemotePlaylist(); + setNextPlaying(); + if(proxy != null) { + proxy.stop(); + proxy = null; + } + + suggestedPlaylistName = null; + suggestedPlaylistId = null; + } + + public synchronized void remove(int which) { + downloadList.remove(which); + currentPlayingIndex = downloadList.indexOf(currentPlaying); + } + + public synchronized void remove(DownloadFile downloadFile) { + if (downloadFile == currentDownloading) { + currentDownloading.cancelDownload(); + currentDownloading = null; + } + if (downloadFile == currentPlaying) { + reset(); + setCurrentPlaying(null, false); + } + downloadList.remove(downloadFile); + currentPlayingIndex = downloadList.indexOf(currentPlaying); + backgroundDownloadList.remove(downloadFile); + revision++; + lifecycleSupport.serializeDownloadQueue(); + updateRemotePlaylist(); + if(downloadFile == nextPlaying) { + setNextPlaying(); + } + } + + public synchronized void delete(List songs) { + for (MusicDirectory.Entry song : songs) { + forSong(song).delete(); + } + } + + public synchronized void unpin(List songs) { + for (MusicDirectory.Entry song : songs) { + forSong(song).unpin(); + } + } + + synchronized void setCurrentPlaying(int currentPlayingIndex, boolean showNotification) { + try { + setCurrentPlaying(downloadList.get(currentPlayingIndex), showNotification); + } catch (IndexOutOfBoundsException x) { + // Ignored + } + } + + synchronized void setCurrentPlaying(DownloadFile currentPlaying, boolean showNotification) { + if(this.currentPlaying != null) { + this.currentPlaying.setPlaying(false); + } + this.currentPlaying = currentPlaying; + if(currentPlaying == null) { + currentPlayingIndex = -1; + setPlayerState(IDLE); + } else { + currentPlayingIndex = downloadList.indexOf(currentPlaying); + } + + if (currentPlaying != null) { + Util.broadcastNewTrackInfo(this, currentPlaying.getSong()); + mRemoteControl.updateMetadata(this, currentPlaying.getSong()); + } else { + Util.broadcastNewTrackInfo(this, null); + Notifications.hidePlayingNotification(this, this, handler); + } + } + + synchronized void setNextPlaying() { + SharedPreferences prefs = Util.getPreferences(DownloadService.this); + + // Only obey gapless playback for local + if(remoteState == LOCAL) { + boolean gaplessPlayback = prefs.getBoolean(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, true); + if (!gaplessPlayback) { + nextPlaying = null; + nextPlayerState = IDLE; + return; + } + } + setNextPlayerState(IDLE); + + int index = getNextPlayingIndex(); + + nextSetup = false; + if(nextPlayingTask != null) { + nextPlayingTask.cancel(); + nextPlayingTask = null; + } + if(index < size() && index != -1 && index != currentPlayingIndex) { + nextPlaying = downloadList.get(index); + + if(remoteState == LOCAL) { + nextPlayingTask = new CheckCompletionTask(nextPlaying); + nextPlayingTask.execute(); + } else if(remoteController != null) { + remoteController.changeNextTrack(nextPlaying); + } + } else { + if(remoteState == LOCAL) { + resetNext(); + } else if(remoteController != null) { + remoteController.changeNextTrack(nextPlaying); + } + nextPlaying = null; + } + } + + public int getCurrentPlayingIndex() { + return currentPlayingIndex; + } + private int getNextPlayingIndex() { + int index = getCurrentPlayingIndex(); + if (index != -1) { + switch (getRepeatMode()) { + case OFF: + index = index + 1; + break; + case ALL: + index = (index + 1) % size(); + break; + case SINGLE: + break; + default: + break; + } + } + return index; + } + + public DownloadFile getCurrentPlaying() { + return currentPlaying; + } + + public DownloadFile getCurrentDownloading() { + return currentDownloading; + } + + public DownloadFile getNextPlaying() { + return nextPlaying; + } + + public List getSongs() { + return downloadList; + } + + public List getToDelete() { return toDelete; } + + public synchronized List getDownloads() { + List temp = new ArrayList(); + temp.addAll(downloadList); + temp.addAll(backgroundDownloadList); + return temp; + } + + public List getBackgroundDownloads() { + return backgroundDownloadList; + } + + /** Plays either the current song (resume) or the first/next one in queue. */ + public synchronized void play() + { + int current = getCurrentPlayingIndex(); + if (current == -1) { + play(0); + } else { + play(current); + } + } + + public synchronized void play(int index) { + play(index, true); + } + private synchronized void play(int index, boolean start) { + play(index, start, 0); + } + private synchronized void play(int index, boolean start, int position) { + int size = this.size(); + if (index < 0 || index >= size) { + reset(); + if(index >= size && size != 0) { + setCurrentPlaying(0, false); + Notifications.hidePlayingNotification(this, this, handler); + } else { + setCurrentPlaying(null, false); + } + lifecycleSupport.serializeDownloadQueue(); + } else { + if(nextPlayingTask != null) { + nextPlayingTask.cancel(); + nextPlayingTask = null; + } + setCurrentPlaying(index, start); + if (start && remoteState != LOCAL) { + remoteController.changeTrack(index, currentPlaying); + } + if (remoteState == LOCAL) { + bufferAndPlay(position, start); + checkDownloads(); + setNextPlaying(); + } + } + } + private synchronized void playNext() { + if(nextPlaying != null && nextPlayerState == PlayerState.PREPARED) { + if(!nextSetup) { + playNext(true); + } else { + nextSetup = false; + playNext(false); + } + } else { + onSongCompleted(); + } + } + private synchronized void playNext(boolean start) { + Util.broadcastPlaybackStatusChange(this, currentPlaying.getSong(), PlayerState.PREPARED); + + // Swap the media players since nextMediaPlayer is ready to play + subtractPosition = 0; + if(start) { + nextMediaPlayer.start(); + } else if(!nextMediaPlayer.isPlaying()) { + Log.w(TAG, "nextSetup lied about it's state!"); + nextMediaPlayer.start(); + } else { + Log.i(TAG, "nextMediaPlayer already playing"); + + // Next time the cachedPosition is updated, use that as position 0 + subtractNextPosition = System.currentTimeMillis(); + } + MediaPlayer tmp = mediaPlayer; + mediaPlayer = nextMediaPlayer; + nextMediaPlayer = tmp; + setCurrentPlaying(nextPlaying, true); + setPlayerState(PlayerState.STARTED); + setupHandlers(currentPlaying, false, start); + setNextPlaying(); + + // Proxy should not be being used here since the next player was already setup to play + if(proxy != null) { + proxy.stop(); + proxy = null; + } + checkDownloads(); + } + + /** Plays or resumes the playback, depending on the current player state. */ + public synchronized void togglePlayPause() { + if (playerState == PAUSED || playerState == COMPLETED || playerState == STOPPED) { + start(); + } else if (playerState == STOPPED || playerState == IDLE) { + autoPlayStart = true; + play(); + } else if (playerState == STARTED) { + pause(); + } + } + + public synchronized void seekTo(int position) { + if(position < 0) { + position = 0; + } + + try { + if (remoteState != LOCAL) { + remoteController.changePosition(position / 1000); + } else { + if(proxy != null && currentPlaying.isCompleteFileAvailable()) { + doPlay(currentPlaying, position, playerState == STARTED); + return; + } + + mediaPlayer.seekTo(position); + cachedPosition = position; + subtractPosition = 0; + } + } catch (Exception x) { + handleError(x); + } + } + + public synchronized void previous() { + int index = getCurrentPlayingIndex(); + if (index == -1) { + return; + } + + // If only one song, just skip within song + if(size() == 1) { + seekTo(getPlayerPosition() - REWIND); + return; + } + + + // Restart song if played more than five seconds. + if (getPlayerPosition() > 5000 || (index == 0 && getRepeatMode() != RepeatMode.ALL)) { + seekTo(0); + } else { + if(index == 0) { + index = size(); + } + + play(index - 1, playerState != PAUSED && playerState != STOPPED && playerState != IDLE); + } + } + + public synchronized void next() { + next(false); + } + public synchronized void next(boolean forceCutoff) { + next(forceCutoff, false); + } + public synchronized void next(boolean forceCutoff, boolean forceStart) { + // If only one song, just skip within song + if(size() == 1) { + seekTo(getPlayerPosition() + FAST_FORWARD); + return; + } else if(playerState == PREPARING || playerState == PREPARED) { + return; + } + + // Delete podcast if fully listened to + int position = getPlayerPosition(); + int duration = getPlayerDuration(); + boolean cutoff; + if(forceCutoff) { + cutoff = true; + } else { + cutoff = isPastCutoff(position, duration); + } + if(currentPlaying != null && currentPlaying.getSong() instanceof PodcastEpisode) { + if(cutoff) { + toDelete.add(currentPlaying); + } + } + if(cutoff) { + clearCurrentBookmark(true); + } + if(currentPlaying != null) { + scrobbler.conditionalScrobble(this, currentPlaying, position, duration); + } + + int index = getCurrentPlayingIndex(); + int nextPlayingIndex = getNextPlayingIndex(); + // Make sure to actually go to next when repeat song is on + if(index == nextPlayingIndex) { + nextPlayingIndex++; + } + if (index != -1 && nextPlayingIndex < size()) { + play(nextPlayingIndex, playerState != PAUSED && playerState != STOPPED && playerState != IDLE || forceStart); + } + } + + public void onSongCompleted() { + play(getNextPlayingIndex()); + } + + public synchronized void pause() { + pause(false); + } + public synchronized void pause(boolean temp) { + try { + if (playerState == STARTED) { + if (remoteState != LOCAL) { + remoteController.stop(); + } else { + mediaPlayer.pause(); + } + setPlayerState(temp ? PAUSED_TEMP : PAUSED); + } else if(playerState == PAUSED_TEMP) { + setPlayerState(temp ? PAUSED_TEMP : PAUSED); + } + } catch (Exception x) { + handleError(x); + } + } + + public synchronized void stop() { + try { + if (playerState == STARTED) { + if (remoteState != LOCAL) { + remoteController.stop(); + setPlayerState(STOPPED); + handler.post(new Runnable() { + @Override + public void run() { + mediaRouter.setDefaultRoute(); + } + }); + } else { + mediaPlayer.pause(); + setPlayerState(STOPPED); + } + } else if(playerState == PAUSED) { + setPlayerState(STOPPED); + } + } catch(Exception x) { + handleError(x); + } + } + + public synchronized void start() { + try { + if (remoteState != LOCAL) { + remoteController.start(); + } else { + // Only start if done preparing + if(playerState != PREPARING) { + mediaPlayer.start(); + } else { + // Otherwise, we need to set it up to start when done preparing + autoPlayStart = true; + } + } + setPlayerState(STARTED); + } catch (Exception x) { + handleError(x); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + public synchronized void reset() { + if (bufferTask != null) { + bufferTask.cancel(); + bufferTask = null; + } + try { + // Only set to idle if it's not being killed to start RemoteController + if(remoteState == LOCAL) { + setPlayerState(IDLE); + } + mediaPlayer.setOnErrorListener(null); + mediaPlayer.setOnCompletionListener(null); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && nextSetup) { + mediaPlayer.setNextMediaPlayer(null); + nextSetup = false; + } + mediaPlayer.reset(); + subtractPosition = 0; + } catch (Exception x) { + handleError(x); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + public synchronized void resetNext() { + try { + if (nextMediaPlayer != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && nextSetup) { + mediaPlayer.setNextMediaPlayer(null); + nextSetup = false; + } + + nextMediaPlayer.setOnCompletionListener(null); + nextMediaPlayer.setOnErrorListener(null); + nextMediaPlayer.reset(); + nextMediaPlayer.release(); + nextMediaPlayer = null; + } + } catch (Exception e) { + Log.w(TAG, "Failed to reset next media player"); + } + } + + public int getPlayerPosition() { + try { + if (playerState == IDLE || playerState == DOWNLOADING || playerState == PREPARING) { + return 0; + } + if (remoteState != LOCAL) { + return remoteController.getRemotePosition() * 1000; + } else { + return Math.max(0, cachedPosition - subtractPosition); + } + } catch (Exception x) { + handleError(x); + return 0; + } + } + + public synchronized int getPlayerDuration() { + if (currentPlaying != null) { + Integer duration = currentPlaying.getSong().getDuration(); + if (duration != null) { + return duration * 1000; + } + } + if (playerState != IDLE && playerState != DOWNLOADING && playerState != PlayerState.PREPARING) { + if(remoteState == LOCAL) { + try { + return mediaPlayer.getDuration(); + } catch (Exception x) { + handleError(x); + } + } else { + return remoteController.getRemoteDuration() * 1000; + } + } + return 0; + } + + public PlayerState getPlayerState() { + return playerState; + } + + public PlayerState getNextPlayerState() { + return nextPlayerState; + } + + public synchronized void setPlayerState(final PlayerState playerState) { + Log.i(TAG, this.playerState.name() + " -> " + playerState.name() + " (" + currentPlaying + ")"); + + if (playerState == PAUSED) { + lifecycleSupport.serializeDownloadQueue(); + } + + boolean show = playerState == PlayerState.STARTED; + boolean pause = playerState == PlayerState.PAUSED; + boolean hide = playerState == PlayerState.STOPPED; + Util.broadcastPlaybackStatusChange(this, (currentPlaying != null) ? currentPlaying.getSong() : null, playerState); + + this.playerState = playerState; + + if(playerState == STARTED) { + Util.requestAudioFocus(this); + } + + if (show) { + Notifications.showPlayingNotification(this, this, handler, currentPlaying.getSong()); + } else if (pause) { + SharedPreferences prefs = Util.getPreferences(this); + if(prefs.getBoolean(Constants.PREFERENCES_KEY_PERSISTENT_NOTIFICATION, false)) { + Notifications.showPlayingNotification(this, this, handler, currentPlaying.getSong()); + } else { + Notifications.hidePlayingNotification(this, this, handler); + } + } else if(hide) { + Notifications.hidePlayingNotification(this, this, handler); + } + if(mRemoteControl != null) { + mRemoteControl.setPlaybackState(playerState.getRemoteControlClientPlayState()); + } + + if (playerState == STARTED) { + scrobbler.scrobble(this, currentPlaying, false); + } else if (playerState == COMPLETED) { + scrobbler.scrobble(this, currentPlaying, true); + } + + if(playerState == STARTED && positionCache == null && remoteState == LOCAL) { + positionCache = new PositionCache(); + Thread thread = new Thread(positionCache, "PositionCache"); + thread.start(); + } else if(playerState != STARTED && positionCache != null) { + positionCache.stop(); + positionCache = null; + } + } + + private class PositionCache implements Runnable { + boolean isRunning = true; + + public void stop() { + isRunning = false; + } + + @Override + public void run() { + // Stop checking position before the song reaches completion + while(isRunning) { + try { + if(mediaPlayer != null && playerState == STARTED) { + int newPosition = mediaPlayer.getCurrentPosition(); + + // If sudden jump in position, something is wrong + if(subtractNextPosition == 0 && newPosition > (cachedPosition + 5000)) { + // Only 1 second should have gone by, subtract the rest + subtractPosition += (newPosition - cachedPosition) - 1000; + } + + cachedPosition = newPosition; + + if(subtractNextPosition > 0) { + // Subtraction amount is current position - how long ago onCompletionListener was called + subtractPosition = cachedPosition - (int) (System.currentTimeMillis() - subtractNextPosition); + if(subtractPosition < 0) { + subtractPosition = 0; + } + subtractNextPosition = 0; + } + } + Thread.sleep(1000L); + } + catch(Exception e) { + Log.w(TAG, "Crashed getting current position", e); + isRunning = false; + positionCache = null; + } + } + } + } + + private void setPlayerStateCompleted() { + Log.i(TAG, this.playerState.name() + " -> " + PlayerState.COMPLETED + " (" + currentPlaying + ")"); + this.playerState = PlayerState.COMPLETED; + if(positionCache != null) { + positionCache.stop(); + positionCache = null; + } + scrobbler.scrobble(this, currentPlaying, true); + } + + public synchronized void setNextPlayerState(PlayerState playerState) { + Log.i(TAG, "Next: " + this.nextPlayerState.name() + " -> " + playerState.name() + " (" + nextPlaying + ")"); + this.nextPlayerState = playerState; + } + + public void setSuggestedPlaylistName(String name, String id) { + this.suggestedPlaylistName = name; + this.suggestedPlaylistId = id; + + SharedPreferences.Editor editor = Util.getPreferences(this).edit(); + editor.putString(Constants.PREFERENCES_KEY_PLAYLIST_NAME, name); + editor.putString(Constants.PREFERENCES_KEY_PLAYLIST_ID, id); + editor.commit(); + } + + public String getSuggestedPlaylistName() { + return suggestedPlaylistName; + } + + public String getSuggestedPlaylistId() { + return suggestedPlaylistId; + } + + public boolean getEqualizerAvailable() { + return effectsController.isAvailable(); + } + + public EqualizerController getEqualizerController() { + EqualizerController controller = null; + try { + controller = effectsController.getEqualizerController(); + if(controller.getEqualizer() == null) { + throw new Exception("Failed to get EQ"); + } + } catch(Exception e) { + Log.w(TAG, "Failed to start EQ, retrying with new mediaPlayer: " + e); + + // If we failed, we are going to try to reinitialize the MediaPlayer + boolean playing = playerState == STARTED; + int pos = getPlayerPosition(); + mediaPlayer.pause(); + Util.sleepQuietly(10L); + reset(); + + try { + // Resetup media player + mediaPlayer.setAudioSessionId(audioSessionId); + mediaPlayer.setDataSource(currentPlaying.getFile().getCanonicalPath()); + + controller = effectsController.getEqualizerController(); + if(controller.getEqualizer() == null) { + throw new Exception("Failed to get EQ"); + } + } catch(Exception e2) { + Log.w(TAG, "Failed to setup EQ even after reinitialization"); + // Don't try again, just resetup media player and continue on + controller = null; + } + + // Restart from same position and state we left off in + play(getCurrentPlayingIndex(), false, pos); + } + + return controller; + } + + public MediaRouteSelector getRemoteSelector() { + return mediaRouter.getSelector(); + } + + public boolean isSeekable() { + if(remoteState == LOCAL) { + return currentPlaying != null && currentPlaying.isWorkDone() && playerState != PREPARING; + } else if(remoteController != null) { + return remoteController.isSeekable(); + } else { + return false; + } + } + + public boolean isRemoteEnabled() { + return remoteState != LOCAL; + } + + public RemoteController getRemoteController() { + return remoteController; + } + + public void setRemoteEnabled(RemoteControlState newState) { + if(instance != null) { + setRemoteEnabled(newState, null); + } + } + public void setRemoteEnabled(RemoteControlState newState, Object ref) { + setRemoteState(newState, ref); + + RouteInfo info = mediaRouter.getSelectedRoute(); + String routeId = info.getId(); + + SharedPreferences.Editor editor = Util.getPreferences(this).edit(); + editor.putInt(Constants.PREFERENCES_KEY_CONTROL_MODE, newState.getValue()); + editor.putString(Constants.PREFERENCES_KEY_CONTROL_ID, routeId); + editor.commit(); + } + private void setRemoteState(RemoteControlState newState, Object ref) { + setRemoteState(newState, ref, null); + } + private void setRemoteState(final RemoteControlState newState, final Object ref, final String routeId) { + // Don't try to do anything if already in the correct state + if(remoteState == newState) { + return; + } + + boolean isPlaying = playerState == STARTED; + int position = getPlayerPosition(); + + if(remoteController != null) { + remoteController.stop(); + setPlayerState(PlayerState.IDLE); + remoteController.shutdown(); + remoteController = null; + + if(newState == LOCAL) { + mediaRouter.setDefaultRoute(); + } + } + + Log.i(TAG, remoteState.name() + " => " + newState.name() + " (" + currentPlaying + ")"); + remoteState = newState; + switch(newState) { + case JUKEBOX_SERVER: + remoteController = new JukeboxController(this, handler); + break; + case CHROMECAST: case DLNA: + if(ref == null) { + remoteState = LOCAL; + break; + } + remoteController = (RemoteController) ref; + break; + case LOCAL: default: + break; + } + + if(remoteController != null) { + remoteController.create(isPlaying, position / 1000); + } else { + play(getCurrentPlayingIndex(), isPlaying, position); + } + + if (remoteState != LOCAL) { + reset(); + + // Cancel current download, if necessary. + if (currentDownloading != null) { + currentDownloading.cancelDownload(); + } + + // Cancels current setup tasks + if(bufferTask != null && bufferTask.isRunning()) { + bufferTask.cancel(); + bufferTask = null; + } + if(nextPlayingTask != null && nextPlayingTask.isRunning()) { + nextPlayingTask.cancel(); + nextPlayingTask = null; + } + } + + if(remoteState == LOCAL) { + checkDownloads(); + } + + if(routeId != null) { + final Runnable delayedReconnect = new Runnable() { + @Override + public void run() { + RouteInfo info = mediaRouter.getRouteForId(routeId); + if(info == null) { + setRemoteState(LOCAL, null); + } else if(newState == RemoteControlState.CHROMECAST) { + RemoteController controller = mediaRouter.getRemoteController(info); + if(controller != null) { + setRemoteState(RemoteControlState.CHROMECAST, controller); + } + } + mediaRouter.stopScan(); + } + }; + + handler.post(new Runnable() { + @Override + public void run() { + mediaRouter.startScan(); + RouteInfo info = mediaRouter.getRouteForId(routeId); + if(info == null) { + handler.postDelayed(delayedReconnect, 2000L); + } else if(newState == RemoteControlState.CHROMECAST) { + RemoteController controller = mediaRouter.getRemoteController(info); + if(controller != null) { + setRemoteState(RemoteControlState.CHROMECAST, controller); + } + } + } + }); + } + } + + public void registerRoute(MediaRouter router) { + if(mRemoteControl != null) { + mRemoteControl.registerRoute(router); + } + } + public void unregisterRoute(MediaRouter router) { + if(mRemoteControl != null) { + mRemoteControl.unregisterRoute(router); + } + } + + public void updateRemoteVolume(boolean up) { + AudioManager audioManager = (AudioManager)getSystemService(Context.AUDIO_SERVICE); + audioManager.adjustVolume(up ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI); + } + + public void startRemoteScan() { + mediaRouter.startScan(); + } + + public void stopRemoteScan() { + mediaRouter.stopScan(); + } + + private synchronized void bufferAndPlay() { + bufferAndPlay(0); + } + private synchronized void bufferAndPlay(int position) { + bufferAndPlay(position, true); + } + private synchronized void bufferAndPlay(int position, boolean start) { + if(!currentPlaying.isCompleteFileAvailable()) { + reset(); + + bufferTask = new BufferTask(currentPlaying, position, start); + bufferTask.execute(); + } else { + doPlay(currentPlaying, position, start); + } + } + + private synchronized void doPlay(final DownloadFile downloadFile, final int position, final boolean start) { + try { + downloadFile.setPlaying(true); + final File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile(); + boolean isPartial = file.equals(downloadFile.getPartialFile()); + downloadFile.updateModificationDate(); + + subtractPosition = 0; + mediaPlayer.setOnCompletionListener(null); + mediaPlayer.setOnPreparedListener(null); + mediaPlayer.setOnErrorListener(null); + mediaPlayer.reset(); + setPlayerState(IDLE); + try { + mediaPlayer.setAudioSessionId(audioSessionId); + } catch(Throwable e) { + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + } + String dataSource = file.getAbsolutePath(); + if(isPartial && !Util.isOffline(this)) { + if (proxy == null) { + proxy = new BufferProxy(this); + proxy.start(); + } + proxy.setBufferFile(downloadFile); + dataSource = proxy.getPrivateAddress(dataSource); + Log.i(TAG, "Data Source: " + dataSource); + } else if(proxy != null) { + proxy.stop(); + proxy = null; + } + mediaPlayer.setDataSource(dataSource); + setPlayerState(PREPARING); + + mediaPlayer.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener() { + public void onBufferingUpdate(MediaPlayer mp, int percent) { + Log.i(TAG, "Buffered " + percent + "%"); + if (percent == 100) { + mediaPlayer.setOnBufferingUpdateListener(null); + } + } + }); + + mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + public void onPrepared(MediaPlayer mediaPlayer) { + try { + setPlayerState(PREPARED); + + synchronized (DownloadService.this) { + if (position != 0) { + Log.i(TAG, "Restarting player from position " + position); + mediaPlayer.seekTo(position); + } + cachedPosition = position; + + applyReplayGain(mediaPlayer, downloadFile); + + if (start || autoPlayStart) { + mediaPlayer.start(); + setPlayerState(STARTED); + + // Disable autoPlayStart after done + autoPlayStart = false; + } else { + setPlayerState(PAUSED); + } + } + + // Only call when starting, setPlayerState(PAUSED) already calls this + if(start) { + lifecycleSupport.serializeDownloadQueue(); + } + } catch (Exception x) { + handleError(x); + } + } + }); + + setupHandlers(downloadFile, isPartial, start); + + mediaPlayer.prepareAsync(); + } catch (Exception x) { + handleError(x); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private synchronized void setupNext(final DownloadFile downloadFile) { + try { + final File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile(); + resetNext(); + + // Exit when using remote controllers + if(remoteState != LOCAL) { + return; + } + + nextMediaPlayer = new MediaPlayer(); + nextMediaPlayer.setWakeMode(DownloadService.this, PowerManager.PARTIAL_WAKE_LOCK); + try { + nextMediaPlayer.setAudioSessionId(audioSessionId); + } catch(Throwable e) { + nextMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + } + nextMediaPlayer.setDataSource(file.getPath()); + setNextPlayerState(PREPARING); + + nextMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + public void onPrepared(MediaPlayer mp) { + try { + setNextPlayerState(PREPARED); + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && (playerState == PlayerState.STARTED || playerState == PlayerState.PAUSED)) { + mediaPlayer.setNextMediaPlayer(nextMediaPlayer); + nextSetup = true; + } + + applyReplayGain(nextMediaPlayer, downloadFile); + } catch (Exception x) { + handleErrorNext(x); + } + } + }); + + nextMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() { + public boolean onError(MediaPlayer mediaPlayer, int what, int extra) { + Log.w(TAG, "Error on playing next " + "(" + what + ", " + extra + "): " + downloadFile); + return true; + } + }); + + nextMediaPlayer.prepareAsync(); + } catch (Exception x) { + handleErrorNext(x); + } + } + + private void setupHandlers(final DownloadFile downloadFile, final boolean isPartial, final boolean isPlaying) { + final int duration = downloadFile.getSong().getDuration() == null ? 0 : downloadFile.getSong().getDuration() * 1000; + mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() { + public boolean onError(MediaPlayer mediaPlayer, int what, int extra) { + Log.w(TAG, "Error on playing file " + "(" + what + ", " + extra + "): " + downloadFile); + int pos = getPlayerPosition(); + reset(); + if (!isPartial || (downloadFile.isWorkDone() && (Math.abs(duration - pos) < 10000))) { + playNext(); + } else { + downloadFile.setPlaying(false); + doPlay(downloadFile, pos, isPlaying); + downloadFile.setPlaying(true); + } + return true; + } + }); + + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mediaPlayer) { + // Acquire a temporary wakelock, since when we return from + // this callback the MediaPlayer will release its wakelock + // and allow the device to go to sleep. + wakeLock.acquire(30000); + + setPlayerStateCompleted(); + + int pos = getPlayerPosition(); + Log.i(TAG, "Ending position " + pos + " of " + duration); + if (!isPartial || (downloadFile.isWorkDone() && (Math.abs(duration - pos) < 10000)) || nextSetup) { + playNext(); + postPlayCleanup(downloadFile); + } else { + // If file is not completely downloaded, restart the playback from the current position. + synchronized (DownloadService.this) { + if (downloadFile.isWorkDone()) { + // Complete was called early even though file is fully buffered + Log.i(TAG, "Requesting restart from " + pos + " of " + duration); + reset(); + downloadFile.setPlaying(false); + doPlay(downloadFile, pos, true); + downloadFile.setPlaying(true); + } else { + Log.i(TAG, "Requesting restart from " + pos + " of " + duration); + reset(); + bufferTask = new BufferTask(downloadFile, pos, true); + bufferTask.execute(); + } + } + checkDownloads(); + } + } + }); + } + + public void setSleepTimerDuration(int duration){ + timerDuration = duration; + } + + public void startSleepTimer(){ + if(sleepTimer != null){ + sleepTimer.cancel(); + sleepTimer.purge(); + } + + sleepTimer = new Timer(); + + sleepTimer.schedule(new TimerTask() { + @Override + public void run() { + pause(); + sleepTimer.cancel(); + sleepTimer.purge(); + sleepTimer = null; + } + + }, timerDuration * 60 * 1000); + } + + public void stopSleepTimer() { + if(sleepTimer != null){ + sleepTimer.cancel(); + sleepTimer.purge(); + } + sleepTimer = null; + } + + public boolean getSleepTimer() { + return sleepTimer != null; + } + + public void setVolume(float volume) { + if(mediaPlayer != null && (playerState == STARTED || playerState == PAUSED || playerState == STOPPED)) { + try { + this.volume = volume; + reapplyVolume(); + } catch(Exception e) { + Log.w(TAG, "Failed to set volume"); + } + } + } + public void reapplyVolume() { + applyReplayGain(mediaPlayer, currentPlaying); + } + + public synchronized void swap(boolean mainList, int from, int to) { + List list = mainList ? downloadList : backgroundDownloadList; + int max = list.size(); + if(to >= max) { + to = max - 1; + } + else if(to < 0) { + to = 0; + } + + DownloadFile movedSong = list.remove(from); + list.add(to, movedSong); + currentPlayingIndex = downloadList.indexOf(currentPlaying); + if(mainList) { + if(remoteState == LOCAL || (remoteController != null && remoteController.isNextSupported())) { + // Moving next playing, current playing, or moving a song to be next playing + if(movedSong == nextPlaying || movedSong == currentPlaying || (currentPlayingIndex + 1) == to) { + setNextPlaying(); + } + } else { + updateRemotePlaylist(); + } + } + } + + public synchronized void serializeQueue() { + serializeQueue(true); + } + public synchronized void serializeQueue(boolean serializeRemote) { + if(playerState == PlayerState.PAUSED) { + lifecycleSupport.serializeDownloadQueue(serializeRemote); + } + } + + private void handleError(Exception x) { + Log.w(TAG, "Media player error: " + x, x); + if(mediaPlayer != null) { + try { + mediaPlayer.reset(); + } catch(Exception e) { + Log.e(TAG, "Failed to reset player in error handler"); + } + } + setPlayerState(IDLE); + } + private void handleErrorNext(Exception x) { + Log.w(TAG, "Next Media player error: " + x, x); + try { + nextMediaPlayer.reset(); + } catch(Exception e) { + Log.e(TAG, "Failed to reset next media player", x); + } + setNextPlayerState(IDLE); + } + + public synchronized void checkDownloads() { + if (!Util.isExternalStoragePresent() || !lifecycleSupport.isExternalStorageAvailable()) { + return; + } + + if(removePlayed) { + checkRemovePlayed(); + } + if (shufflePlay) { + checkShufflePlay(); + } + if(artistRadio) { + checkArtistRadio(); + } + + if (!Util.isNetworkConnected(this, true) || Util.isOffline(this)) { + return; + } + + if (downloadList.isEmpty() && backgroundDownloadList.isEmpty()) { + return; + } + + // Need to download current playing and not casting? + if (currentPlaying != null && remoteState == LOCAL && currentPlaying != currentDownloading && !currentPlaying.isWorkDone()) { + // Cancel current download, if necessary. + if (currentDownloading != null) { + currentDownloading.cancelDownload(); + } + + currentDownloading = currentPlaying; + currentDownloading.download(); + cleanupCandidates.add(currentDownloading); + } + + // Find a suitable target for download. + else if (currentDownloading == null || currentDownloading.isWorkDone() || currentDownloading.isFailed() && ((!downloadList.isEmpty() && remoteState == LOCAL) || !backgroundDownloadList.isEmpty())) { + currentDownloading = null; + int n = size(); + + int preloaded = 0; + + if(n != 0 && remoteState == LOCAL) { + int start = currentPlaying == null ? 0 : getCurrentPlayingIndex(); + if(start == -1) { + start = 0; + } + int i = start; + do { + DownloadFile downloadFile = downloadList.get(i); + if (!downloadFile.isWorkDone() && !downloadFile.isFailedMax()) { + if (downloadFile.shouldSave() || preloaded < Util.getPreloadCount(this)) { + currentDownloading = downloadFile; + currentDownloading.download(); + cleanupCandidates.add(currentDownloading); + if(i == (start + 1)) { + setNextPlayerState(DOWNLOADING); + } + break; + } + } else if (currentPlaying != downloadFile) { + preloaded++; + } + + i = (i + 1) % n; + } while (i != start); + } + + if((preloaded + 1 == n || preloaded >= Util.getPreloadCount(this) || downloadList.isEmpty() || remoteState != LOCAL) && !backgroundDownloadList.isEmpty()) { + for(int i = 0; i < backgroundDownloadList.size(); i++) { + DownloadFile downloadFile = backgroundDownloadList.get(i); + if(downloadFile.isWorkDone() && (!downloadFile.shouldSave() || downloadFile.isSaved()) || downloadFile.isFailedMax()) { + // Don't need to keep list like active song list + backgroundDownloadList.remove(i); + revision++; + i--; + } else { + currentDownloading = downloadFile; + currentDownloading.download(); + cleanupCandidates.add(currentDownloading); + break; + } + } + } + } + + if(!backgroundDownloadList.isEmpty()) { + Notifications.showDownloadingNotification(this, this, handler, currentDownloading, backgroundDownloadList.size()); + downloadOngoing = true; + } else if(backgroundDownloadList.isEmpty() && downloadOngoing) { + Notifications.hideDownloadingNotification(this, this, handler); + downloadOngoing = false; + } + + // Delete obsolete .partial and .complete files. + cleanup(); + } + + private synchronized void checkRemovePlayed() { + while(currentPlayingIndex > 0) { + downloadList.remove(0); + currentPlayingIndex = downloadList.indexOf(currentPlaying); + revision++; + } + } + + private synchronized void checkShufflePlay() { + + // Get users desired random playlist size + SharedPreferences prefs = Util.getPreferences(this); + int listSize = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_RANDOM_SIZE, "20")); + boolean wasEmpty = downloadList.isEmpty(); + + long revisionBefore = revision; + + // First, ensure that list is at least 20 songs long. + int size = size(); + if (size < listSize) { + for (MusicDirectory.Entry song : shufflePlayBuffer.get(listSize - size)) { + DownloadFile downloadFile = new DownloadFile(this, song, false); + downloadList.add(downloadFile); + revision++; + } + } + + int currIndex = currentPlaying == null ? 0 : getCurrentPlayingIndex(); + + // Only shift playlist if playing song #5 or later. + if (currIndex > 4) { + int songsToShift = currIndex - 2; + for (MusicDirectory.Entry song : shufflePlayBuffer.get(songsToShift)) { + downloadList.add(new DownloadFile(this, song, false)); + downloadList.get(0).cancelDownload(); + downloadList.remove(0); + revision++; + } + } + currentPlayingIndex = downloadList.indexOf(currentPlaying); + + if (revisionBefore != revision) { + updateRemotePlaylist(); + } + + if (wasEmpty && !downloadList.isEmpty()) { + play(0); + } + } + + private synchronized void checkArtistRadio() { + // Get users desired random playlist size + SharedPreferences prefs = Util.getPreferences(this); + int listSize = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_RANDOM_SIZE, "20")); + boolean wasEmpty = downloadList.isEmpty(); + + long revisionBefore = revision; + + // First, ensure that list is at least 20 songs long. + int size = size(); + if (size < listSize) { + for (MusicDirectory.Entry song : artistRadioBuffer.get(listSize - size)) { + DownloadFile downloadFile = new DownloadFile(this, song, false); + downloadList.add(downloadFile); + revision++; + } + } + + int currIndex = currentPlaying == null ? 0 : getCurrentPlayingIndex(); + + // Only shift playlist if playing song #5 or later. + if (currIndex > 4) { + int songsToShift = currIndex - 2; + for (MusicDirectory.Entry song : artistRadioBuffer.get(songsToShift)) { + downloadList.add(new DownloadFile(this, song, false)); + downloadList.get(0).cancelDownload(); + downloadList.remove(0); + revision++; + } + } + currentPlayingIndex = downloadList.indexOf(currentPlaying); + + if (revisionBefore != revision) { + updateRemotePlaylist(); + } + + if (wasEmpty && !downloadList.isEmpty()) { + play(0); + } + } + + public long getDownloadListUpdateRevision() { + return revision; + } + + private synchronized void cleanup() { + Iterator iterator = cleanupCandidates.iterator(); + while (iterator.hasNext()) { + DownloadFile downloadFile = iterator.next(); + if (downloadFile != currentPlaying && downloadFile != currentDownloading) { + if (downloadFile.cleanup()) { + iterator.remove(); + } + } + } + } + + public void postPlayCleanup() { + postPlayCleanup(currentPlaying); + } + public void postPlayCleanup(DownloadFile downloadFile) { + if(downloadFile == null) { + return; + } + + // Finished loading, delete when list is cleared + if (downloadFile.getSong() instanceof PodcastEpisode) { + toDelete.add(downloadFile); + } + clearCurrentBookmark(downloadFile.getSong(), true); + } + + private boolean isPastCutoff() { + return isPastCutoff(getPlayerPosition(), getPlayerDuration()); + } + private boolean isPastCutoff(int position, int duration) { + return isPastCutoff(position, duration, false); + } + private boolean isPastCutoff(int position, int duration, boolean allowSkipping) { + if(currentPlaying == null) { + return false; + } + + int cutoffPoint = (int) (duration * DELETE_CUTOFF); + boolean isPastCutoff = duration > 0 && position > cutoffPoint; + + // Check to make sure song isn't within 10 seconds of where it was created + MusicDirectory.Entry entry = currentPlaying.getSong(); + if(entry != null && entry.getBookmark() != null) { + Bookmark bookmark = entry.getBookmark(); + if(position < (bookmark.getPosition() + 10000)) { + isPastCutoff = false; + } + } + + // Check to make sure we aren't in a series of similar content before deleting bookmark + if(isPastCutoff && allowSkipping) { + // Check to make sure: + // Is an audio book + // Next playing exists and is not a wrap around or a shuffle + // Next playing is from same context as current playing, so not at end of list + if(entry.isAudioBook() && nextPlaying != null && downloadList.indexOf(nextPlaying) != 0 && !shufflePlay + && entry.getParent() != null && entry.getParent().equals(nextPlaying.getSong().getParent())) { + isPastCutoff = false; + } + } + + return isPastCutoff; + } + + private void clearCurrentBookmark() { + clearCurrentBookmark(false); + } + private void clearCurrentBookmark(boolean checkDelete) { + // If current is null, nothing to do + if(currentPlaying == null) { + return; + } + + clearCurrentBookmark(currentPlaying.getSong(), checkDelete); + } + private void clearCurrentBookmark(final MusicDirectory.Entry entry, boolean checkDelete) { + // If no bookmark, move on + if(entry.getBookmark() == null) { + return; + } + + // If delete is not specified, check position + if(!checkDelete) { + checkDelete = isPastCutoff(); + } + + // If supposed to delete + if(checkDelete) { + new SilentBackgroundTask(this) { + @Override + public Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(DownloadService.this); + entry.setBookmark(null); + musicService.deleteBookmark(entry, DownloadService.this, null); + + MusicDirectory.Entry found = UpdateView.findEntry(entry); + if(found != null) { + found.setBookmark(null); + } + return null; + } + + @Override + public void error(Throwable error) { + Log.e(TAG, "Failed to delete bookmark", error); + + String msg; + if(error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = DownloadService.this.getResources().getString(R.string.bookmark_deleted_error, entry.getTitle()) + " " + getErrorMessage(error); + } + + Util.toast(DownloadService.this, msg, false); + } + }.execute(); + } + } + + private void checkAddBookmark() { + // Don't do anything if no current playing + if(currentPlaying == null || !ServerInfo.canBookmark(this)) { + return; + } + + final MusicDirectory.Entry entry = currentPlaying.getSong(); + int duration = getPlayerDuration(); + + // If song is podcast or long go ahead and auto add a bookmark + if(entry.isPodcast() || entry.isAudioBook() || duration > (10L * 60L * 1000L)) { + final Context context = this; + final int position = getPlayerPosition(); + + // Don't bother when at beginning + if(position < 5000L) { + return; + } + + new SilentBackgroundTask(context) { + @Override + public Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + entry.setBookmark(new Bookmark(position)); + musicService.createBookmark(entry, position, "Auto created by DSub", context, null); + + MusicDirectory.Entry found = UpdateView.findEntry(entry); + if(found != null) { + found.setBookmark(new Bookmark(position)); + } + + return null; + } + + @Override + public void error(Throwable error) { + Log.w(TAG, "Failed to create automatic bookmark", error); + + String msg; + if(error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.download_save_bookmark_failed) + getErrorMessage(error); + } + + Util.toast(context, msg, false); + } + }.execute(); + } + } + + private void applyReplayGain(MediaPlayer mediaPlayer, DownloadFile downloadFile) { + if(currentPlaying == null) { + return; + } + + SharedPreferences prefs = Util.getPreferences(this); + try { + float[] rg = BastpUtil.getReplayGainValues(downloadFile.getFile().getCanonicalPath()); /* track, album */ + float adjust = 0f; + if (prefs.getBoolean(Constants.PREFERENCES_KEY_REPLAY_GAIN, false)) { + boolean singleAlbum = false; + + String replayGainType = prefs.getString(Constants.PREFERENCES_KEY_REPLAY_GAIN_TYPE, "1"); + // 1 => Smart replay gain + if("1".equals(replayGainType)) { + // Check if part of at least consequetive songs of the same album + + int index = downloadList.indexOf(downloadFile); + if(index != -1) { + String albumName = downloadFile.getSong().getAlbum(); + int matched = 0; + + // Check forwards + for(int i = index + 1; i < downloadList.size() && matched < REQUIRED_ALBUM_MATCHES; i++) { + if(albumName.equals(downloadList.get(i).getSong().getAlbum())) { + matched++; + } else { + break; + } + } + + // Check backwards + for(int i = index - 1; i >= 0 && matched < REQUIRED_ALBUM_MATCHES; i--) { + if(albumName.equals(downloadList.get(i).getSong().getAlbum())) { + matched++; + } else { + break; + } + } + + if(matched >= REQUIRED_ALBUM_MATCHES) { + singleAlbum = true; + } + } + } + // 2 => Use album tags + else if("2".equals(replayGainType)) { + singleAlbum = true; + } + // 3 => Use track tags + // Already false, no need to do anything here + + + // If playing a single album or no track gain, use album gain + if((singleAlbum || rg[0] == 0) && rg[1] != 0) { + adjust = rg[1]; + } else { + // Otherwise, give priority to track gain + adjust = rg[0]; + } + + if (adjust == 0) { + /* No RG value found: decrease volume for untagged song if requested by user */ + int untagged = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_REPLAY_GAIN_UNTAGGED, "0")); + adjust = (untagged - 150) / 10f; + } else { + int bump = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_REPLAY_GAIN_BUMP, "150")); + adjust += (bump - 150) / 10f; + } + } + + float rg_result = ((float) Math.pow(10, (adjust / 20))) * volume; + if (rg_result > 1.0f) { + rg_result = 1.0f; /* android would IGNORE the change if this is > 1 and we would end up with the wrong volume */ + } else if (rg_result < 0.0f) { + rg_result = 0.0f; + } + mediaPlayer.setVolume(rg_result, rg_result); + } catch(IOException e) { + Log.w(TAG, "Failed to apply replay gain values", e); + } + } + + private class BufferTask extends SilentBackgroundTask { + private final DownloadFile downloadFile; + private final int position; + private final long expectedFileSize; + private final File partialFile; + private final boolean start; + + public BufferTask(DownloadFile downloadFile, int position, boolean start) { + super(instance); + this.downloadFile = downloadFile; + this.position = position; + partialFile = downloadFile.getPartialFile(); + this.start = start; + + // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to. + int bitRate = downloadFile.getBitRate(); + long byteCount = Math.max(100000, bitRate * 1024L / 8L * 5L); + + // Find out how large the file should grow before resuming playback. + Log.i(TAG, "Buffering from position " + position + " and bitrate " + bitRate); + expectedFileSize = (position * bitRate / 8) + byteCount; + } + + @Override + public Void doInBackground() throws InterruptedException { + setPlayerState(DOWNLOADING); + + while (!bufferComplete()) { + Thread.sleep(1000L); + if (isCancelled() || downloadFile.isFailedMax()) { + return null; + } else if(!downloadFile.isFailedMax() && !downloadFile.isDownloading()) { + checkDownloads(); + } + } + doPlay(downloadFile, position, start); + + return null; + } + + private boolean bufferComplete() { + boolean completeFileAvailable = downloadFile.isWorkDone(); + long size = partialFile.length(); + + Log.i(TAG, "Buffering " + partialFile + " (" + size + "/" + expectedFileSize + ", " + completeFileAvailable + ")"); + return completeFileAvailable || size >= expectedFileSize; + } + + @Override + public String toString() { + return "BufferTask (" + downloadFile + ")"; + } + } + + private class CheckCompletionTask extends SilentBackgroundTask { + private final DownloadFile downloadFile; + private final File partialFile; + + public CheckCompletionTask(DownloadFile downloadFile) { + super(instance); + this.downloadFile = downloadFile; + if(downloadFile != null) { + partialFile = downloadFile.getPartialFile(); + } else { + partialFile = null; + } + } + + @Override + public Void doInBackground() throws InterruptedException { + if(downloadFile == null) { + return null; + } + + // Do an initial sleep so this prepare can't compete with main prepare + Thread.sleep(5000L); + while (!bufferComplete()) { + Thread.sleep(5000L); + if (isCancelled()) { + return null; + } + } + + // Start the setup of the next media player + mediaPlayerHandler.post(new Runnable() { + public void run() { + setupNext(downloadFile); + } + }); + return null; + } + + private boolean bufferComplete() { + boolean completeFileAvailable = downloadFile.isWorkDone(); + Log.i(TAG, "Buffering next " + partialFile + " (" + partialFile.length() + "): " + completeFileAvailable); + return completeFileAvailable && (playerState == PlayerState.STARTED || playerState == PlayerState.PAUSED); + } + + @Override + public String toString() { + return "CheckCompletionTask (" + downloadFile + ")"; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/DownloadServiceLifecycleSupport.java b/app/src/main/java/github/daneren2005/dsub/service/DownloadServiceLifecycleSupport.java new file mode 100644 index 00000000..c9f92f41 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/DownloadServiceLifecycleSupport.java @@ -0,0 +1,445 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.atomic.AtomicBoolean; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.media.RemoteControlClient; +import android.os.Handler; +import android.os.Looper; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.util.Log; +import android.view.KeyEvent; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.CacheCleaner; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Util; + +import static github.daneren2005.dsub.domain.PlayerState.PREPARING; + +/** + * @author Sindre Mehus + */ +public class DownloadServiceLifecycleSupport { + private static final String TAG = DownloadServiceLifecycleSupport.class.getSimpleName(); + public static final String FILENAME_DOWNLOADS_SER = "downloadstate2.ser"; + + private final DownloadService downloadService; + private Looper eventLooper; + private Handler eventHandler; + private BroadcastReceiver ejectEventReceiver; + private PhoneStateListener phoneStateListener; + private boolean externalStorageAvailable= true; + private ReentrantLock lock = new ReentrantLock(); + private final AtomicBoolean setup = new AtomicBoolean(false); + private long lastPressTime = 0; + private SilentBackgroundTask currentSavePlayQueueTask = null; + private Date lastChange = null; + + /** + * This receiver manages the intent that could come from other applications. + */ + private BroadcastReceiver intentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + eventHandler.post(new Runnable() { + @Override + public void run() { + String action = intent.getAction(); + Log.i(TAG, "intentReceiver.onReceive: " + action); + if (DownloadService.CMD_PLAY.equals(action)) { + downloadService.play(); + } else if (DownloadService.CMD_NEXT.equals(action)) { + downloadService.next(); + } else if (DownloadService.CMD_PREVIOUS.equals(action)) { + downloadService.previous(); + } else if (DownloadService.CMD_TOGGLEPAUSE.equals(action)) { + downloadService.togglePlayPause(); + } else if (DownloadService.CMD_PAUSE.equals(action)) { + downloadService.pause(); + } else if (DownloadService.CMD_STOP.equals(action)) { + downloadService.pause(); + downloadService.seekTo(0); + } + } + }); + } + }; + + + public DownloadServiceLifecycleSupport(DownloadService downloadService) { + this.downloadService = downloadService; + } + + public void onCreate() { + new Thread(new Runnable() { + @Override + public void run() { + Looper.prepare(); + eventLooper = Looper.myLooper(); + eventHandler = new Handler(eventLooper); + + // Deserialize queue before starting looper + try { + lock.lock(); + deserializeDownloadQueueNow(); + + // Wait until PREPARING is done to mark lifecycle as ready to receive events + while(downloadService.getPlayerState() == PREPARING) { + Util.sleepQuietly(50L); + } + + setup.set(true); + } finally { + lock.unlock(); + } + + Looper.loop(); + } + }, "DownloadServiceLifecycleSupport").start(); + + // Stop when SD card is ejected. + ejectEventReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + externalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED.equals(intent.getAction()); + if (!externalStorageAvailable) { + Log.i(TAG, "External media is ejecting. Stopping playback."); + downloadService.reset(); + } else { + Log.i(TAG, "External media is available."); + } + } + }; + IntentFilter ejectFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT); + ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED); + ejectFilter.addDataScheme("file"); + downloadService.registerReceiver(ejectEventReceiver, ejectFilter); + + // React to media buttons. + Util.registerMediaButtonEventReceiver(downloadService); + + // Pause temporarily on incoming phone calls. + phoneStateListener = new MyPhoneStateListener(); + TelephonyManager telephonyManager = (TelephonyManager) downloadService.getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + + // Register the handler for outside intents. + IntentFilter commandFilter = new IntentFilter(); + commandFilter.addAction(DownloadService.CMD_PLAY); + commandFilter.addAction(DownloadService.CMD_TOGGLEPAUSE); + commandFilter.addAction(DownloadService.CMD_PAUSE); + commandFilter.addAction(DownloadService.CMD_STOP); + commandFilter.addAction(DownloadService.CMD_PREVIOUS); + commandFilter.addAction(DownloadService.CMD_NEXT); + commandFilter.addAction(DownloadService.CANCEL_DOWNLOADS); + downloadService.registerReceiver(intentReceiver, commandFilter); + + new CacheCleaner(downloadService, downloadService).clean(); + } + + public boolean isInitialized() { + return setup.get(); + } + + public void onStart(final Intent intent) { + if (intent != null) { + final String action = intent.getAction(); + + if(eventHandler == null) { + Util.sleepQuietly(100L); + } + if(eventHandler == null) { + return; + } + + eventHandler.post(new Runnable() { + @Override + public void run() { + if(!setup.get()) { + lock.lock(); + lock.unlock(); + } + + if(DownloadService.START_PLAY.equals(action)) { + int offlinePref = intent.getIntExtra(Constants.PREFERENCES_KEY_OFFLINE, 0); + if(offlinePref != 0) { + boolean offline = (offlinePref == 2); + Util.setOffline(downloadService, offline); + if (offline) { + downloadService.clearIncomplete(); + } else { + downloadService.checkDownloads(); + } + } + + if(intent.getBooleanExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, false)) { + // Add shuffle parameters + SharedPreferences.Editor editor = Util.getPreferences(downloadService).edit(); + String startYear = intent.getStringExtra(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR); + if(startYear != null) { + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, startYear); + } + + String endYear = intent.getStringExtra(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR); + if(endYear != null) { + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, endYear); + } + + String genre = intent.getStringExtra(Constants.PREFERENCES_KEY_SHUFFLE_GENRE); + if(genre != null) { + editor.putString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, genre); + } + editor.commit(); + + downloadService.setShufflePlayEnabled(true); + } else { + downloadService.start(); + } + } else if(DownloadService.CMD_TOGGLEPAUSE.equals(action)) { + downloadService.togglePlayPause(); + } else if(DownloadService.CMD_NEXT.equals(action)) { + downloadService.next(); + } else if(DownloadService.CMD_PREVIOUS.equals(action)) { + downloadService.previous(); + } else if(DownloadService.CANCEL_DOWNLOADS.equals(action)) { + downloadService.clearBackground(); + } else if(intent.getExtras() != null) { + final KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); + if (event != null) { + handleKeyEvent(event); + } + } + } + }); + } + } + + public void onDestroy() { + serializeDownloadQueue(); + eventLooper.quit(); + downloadService.unregisterReceiver(ejectEventReceiver); + downloadService.unregisterReceiver(intentReceiver); + + TelephonyManager telephonyManager = (TelephonyManager) downloadService.getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); + } + + public boolean isExternalStorageAvailable() { + return externalStorageAvailable; + } + + public void serializeDownloadQueue() { + serializeDownloadQueue(true); + } + public void serializeDownloadQueue(final boolean serializeRemote) { + if(!setup.get()) { + return; + } + + final List songs = new ArrayList(downloadService.getSongs()); + eventHandler.post(new Runnable() { + @Override + public void run() { + if(lock.tryLock()) { + try { + serializeDownloadQueueNow(songs, serializeRemote); + } finally { + lock.unlock(); + } + } + } + }); + } + + public void serializeDownloadQueueNow(List songs, boolean serializeRemote) { + final PlayerQueue state = new PlayerQueue(); + for (DownloadFile downloadFile : songs) { + state.songs.add(downloadFile.getSong()); + } + for (DownloadFile downloadFile : downloadService.getToDelete()) { + state.toDelete.add(downloadFile.getSong()); + } + state.currentPlayingIndex = downloadService.getCurrentPlayingIndex(); + state.currentPlayingPosition = downloadService.getPlayerPosition(); + + DownloadFile currentPlaying = downloadService.getCurrentPlaying(); + if(currentPlaying != null) { + state.renameCurrent = currentPlaying.isWorkDone() && !currentPlaying.isCompleteFileAvailable(); + } + state.changed = lastChange = new Date(); + + Log.i(TAG, "Serialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition); + FileUtil.serialize(downloadService, state, FILENAME_DOWNLOADS_SER); + + // If we are on Subsonic 5.2+, save play queue + if(serializeRemote && ServerInfo.canSavePlayQueue(downloadService) && !Util.isOffline(downloadService) && state.songs.size() > 0) { + // Cancel any currently running tasks + if(currentSavePlayQueueTask != null) { + currentSavePlayQueueTask.cancel(); + } + + currentSavePlayQueueTask = new SilentBackgroundTask(downloadService) { + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(downloadService); + musicService.savePlayQueue(state.songs, state.songs.get(state.currentPlayingIndex), state.currentPlayingPosition, downloadService, null); + currentSavePlayQueueTask = null; + } catch (Exception e) { + Log.e(TAG, "Failed to save playing queue to server", e); + currentSavePlayQueueTask = null; + } + + return null; + } + + @Override + protected void error(Throwable error) { + currentSavePlayQueueTask = null; + super.error(error); + } + }; + currentSavePlayQueueTask.execute(); + } + } + + public void post(Runnable runnable) { + eventHandler.post(runnable); + } + + private void deserializeDownloadQueueNow() { + PlayerQueue state = FileUtil.deserialize(downloadService, FILENAME_DOWNLOADS_SER, PlayerQueue.class); + if (state == null) { + return; + } + Log.i(TAG, "Deserialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition); + + // Rename first thing before anything else starts + if(state.renameCurrent && state.currentPlayingIndex != -1 && state.currentPlayingIndex < state.songs.size()) { + DownloadFile currentPlaying = new DownloadFile(downloadService, state.songs.get(state.currentPlayingIndex), false); + currentPlaying.renamePartial(); + } + + downloadService.restore(state.songs, state.toDelete, state.currentPlayingIndex, state.currentPlayingPosition); + + if(state != null) { + lastChange = state.changed; + } + } + + public Date getLastChange() { + return lastChange; + } + + private void handleKeyEvent(KeyEvent event) { + if(event.getAction() == KeyEvent.ACTION_DOWN) { + switch (event.getKeyCode()) { + case RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + downloadService.togglePlayPause(); + break; + case KeyEvent.KEYCODE_HEADSETHOOK: + if(lastPressTime < (System.currentTimeMillis() - 500)) { + lastPressTime = System.currentTimeMillis(); + downloadService.togglePlayPause(); + } else { + downloadService.next(false, true); + } + break; + case RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + downloadService.previous(); + break; + case RemoteControlClient.FLAG_KEY_MEDIA_NEXT: + case KeyEvent.KEYCODE_MEDIA_NEXT: + downloadService.next(); + break; + case RemoteControlClient.FLAG_KEY_MEDIA_STOP: + case KeyEvent.KEYCODE_MEDIA_STOP: + downloadService.stop(); + break; + case RemoteControlClient.FLAG_KEY_MEDIA_PLAY: + case KeyEvent.KEYCODE_MEDIA_PLAY: + if(downloadService.getPlayerState() != PlayerState.STARTED) { + downloadService.start(); + } + break; + case RemoteControlClient.FLAG_KEY_MEDIA_PAUSE: + case KeyEvent.KEYCODE_MEDIA_PAUSE: + downloadService.pause(); + default: + break; + } + } + } + + /** + * Logic taken from packages/apps/Music. Will pause when an incoming + * call rings or if a call (incoming or outgoing) is connected. + */ + private class MyPhoneStateListener extends PhoneStateListener { + private boolean resumeAfterCall; + + @Override + public void onCallStateChanged(final int state, String incomingNumber) { + eventHandler.post(new Runnable() { + @Override + public void run() { + switch (state) { + case TelephonyManager.CALL_STATE_RINGING: + case TelephonyManager.CALL_STATE_OFFHOOK: + if (downloadService.getPlayerState() == PlayerState.STARTED) { + resumeAfterCall = true; + downloadService.pause(true); + } + break; + case TelephonyManager.CALL_STATE_IDLE: + if (resumeAfterCall) { + resumeAfterCall = false; + if(downloadService.getPlayerState() == PlayerState.PAUSED_TEMP) { + downloadService.start(); + } + } + break; + default: + break; + } + } + }); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/HeadphoneListenerService.java b/app/src/main/java/github/daneren2005/dsub/service/HeadphoneListenerService.java new file mode 100644 index 00000000..f4375c50 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/HeadphoneListenerService.java @@ -0,0 +1,66 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service; + +import android.app.Service; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.IBinder; + +import github.daneren2005.dsub.receiver.HeadphonePlugReceiver; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 4/6/2015. + */ +public class HeadphoneListenerService extends Service { + private HeadphonePlugReceiver receiver; + + @Override + public void onCreate() { + super.onCreate(); + + receiver = new HeadphonePlugReceiver(); + registerReceiver(receiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG)); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if(!Util.shouldStartOnHeadphones(this)) { + stopSelf(); + } + + return Service.START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + try { + if(receiver != null) { + unregisterReceiver(receiver); + } + } catch(Exception e) { + // Don't care + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/JukeboxController.java b/app/src/main/java/github/daneren2005/dsub/service/JukeboxController.java new file mode 100644 index 00000000..e9d7cbc8 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/JukeboxController.java @@ -0,0 +1,307 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2009 (C) Sindre Mehus +*/ + +package github.daneren2005.dsub.service; + +import android.os.Handler; +import android.util.Log; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.RemoteStatus; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.service.parser.SubsonicRESTException; +import github.daneren2005.dsub.util.Util; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +public class JukeboxController extends RemoteController { + private static final String TAG = JukeboxController.class.getSimpleName(); + private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L; + + private final Handler handler; + private boolean running = false; + private final TaskQueue tasks = new TaskQueue(); + private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + private ScheduledFuture statusUpdateFuture; + private final AtomicLong timeOfLastUpdate = new AtomicLong(); + private RemoteStatus jukeboxStatus; + private float gain = 0.5f; + + public JukeboxController(DownloadService downloadService, Handler handler) { + this.downloadService = downloadService; + this.handler = handler; + } + + @Override + public void create(boolean playing, int seconds) { + new Thread("JukeboxController") { + @Override + public void run() { + running = true; + processTasks(); + } + }.start(); + updatePlaylist(); + // Best I can do since API doesn't support seeking without starting playback + if(seconds != 0 && playing) { + changePosition(seconds); + } + } + + @Override + public void start() { + tasks.remove(Stop.class); + tasks.remove(Start.class); + + startStatusUpdate(); + tasks.add(new Start()); + } + @Override + public void stop() { + tasks.remove(Stop.class); + tasks.remove(Start.class); + + stopStatusUpdate(); + tasks.add(new Stop()); + } + @Override + public void shutdown() { + running = false; + } + + @Override + public void updatePlaylist() { + tasks.remove(Skip.class); + tasks.remove(Stop.class); + tasks.remove(Start.class); + + List ids = new ArrayList(); + for (DownloadFile file : downloadService.getDownloads()) { + ids.add(file.getSong().getId()); + } + tasks.add(new SetPlaylist(ids)); + } + @Override + public void changePosition(int seconds) { + tasks.remove(Skip.class); + tasks.remove(Stop.class); + tasks.remove(Start.class); + + startStatusUpdate(); + if (jukeboxStatus != null) { + jukeboxStatus.setPositionSeconds(seconds); + } + tasks.add(new Skip(downloadService.getCurrentPlayingIndex(), seconds)); + downloadService.setPlayerState(PlayerState.STARTED); + } + @Override + public void changeTrack(int index, DownloadFile song) { + tasks.remove(Skip.class); + tasks.remove(Stop.class); + tasks.remove(Start.class); + + startStatusUpdate(); + tasks.add(new Skip(index, 0)); + downloadService.setPlayerState(PlayerState.STARTED); + } + @Override + public void setVolume(int volume) { + gain = volume / 10.0f; + + tasks.remove(SetGain.class); + tasks.add(new SetGain(gain)); + } + @Override + public void updateVolume(boolean up) { + float delta = up ? 0.1f : -0.1f; + gain += delta; + gain = Math.max(gain, 0.0f); + gain = Math.min(gain, 1.0f); + + tasks.remove(SetGain.class); + tasks.add(new SetGain(gain)); + } + @Override + public double getVolume() { + return gain; + } + + @Override + public int getRemotePosition() { + if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0) { + return 0; + } + + if (jukeboxStatus.isPlaying()) { + int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L); + return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate; + } + + return jukeboxStatus.getPositionSeconds(); + } + + private void processTasks() { + while (running) { + RemoteTask task = null; + try { + task = tasks.take(); + RemoteStatus status = task.execute(); + if(status != null && running) { + onStatusUpdate(status); + } + } catch (Throwable x) { + onError(task, x); + } + } + } + + private synchronized void startStatusUpdate() { + stopStatusUpdate(); + Runnable updateTask = new Runnable() { + @Override + public void run() { + tasks.remove(GetStatus.class); + tasks.add(new GetStatus()); + } + }; + statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, + STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + private synchronized void stopStatusUpdate() { + if (statusUpdateFuture != null) { + statusUpdateFuture.cancel(false); + statusUpdateFuture = null; + } + } + + private void onStatusUpdate(RemoteStatus jukeboxStatus) { + timeOfLastUpdate.set(System.currentTimeMillis()); + this.jukeboxStatus = jukeboxStatus; + + // Track change? + Integer index = jukeboxStatus.getCurrentPlayingIndex(); + if (index != null && index != -1 && index != downloadService.getCurrentPlayingIndex()) { + downloadService.setPlayerState(PlayerState.COMPLETED); + downloadService.setCurrentPlaying(index, true); + if(jukeboxStatus.isPlaying()) { + downloadService.setPlayerState(PlayerState.STARTED); + } + } + } + + private void onError(RemoteTask task, Throwable x) { + if (x instanceof ServerTooOldException && !(task instanceof Stop)) { + disableJukeboxOnError(x, R.string.download_jukebox_server_too_old); + } else if (x instanceof OfflineException && !(task instanceof Stop)) { + disableJukeboxOnError(x, R.string.download_jukebox_offline); + } else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop)) { + disableJukeboxOnError(x, R.string.download_jukebox_not_authorized); + } else { + Log.e(TAG, "Failed to process jukebox task: " + x, x); + } + } + + private void disableJukeboxOnError(Throwable x, final int resourceId) { + Log.w(TAG, x.toString()); + handler.post(new Runnable() { + @Override + public void run() { + Util.toast(downloadService, resourceId, false); + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + } + }); + } + + private MusicService getMusicService() { + return MusicServiceFactory.getMusicService(downloadService); + } + + private class GetStatus extends RemoteTask { + @Override + RemoteStatus execute() throws Exception { + return getMusicService().getJukeboxStatus(downloadService, null); + } + } + + private class SetPlaylist extends RemoteTask { + private final List ids; + + SetPlaylist(List ids) { + this.ids = ids; + } + + @Override + RemoteStatus execute() throws Exception { + return getMusicService().updateJukeboxPlaylist(ids, downloadService, null); + } + } + + private class Skip extends RemoteTask { + private final int index; + private final int offsetSeconds; + + Skip(int index, int offsetSeconds) { + this.index = index; + this.offsetSeconds = offsetSeconds; + } + + @Override + RemoteStatus execute() throws Exception { + return getMusicService().skipJukebox(index, offsetSeconds, downloadService, null); + } + } + + private class Stop extends RemoteTask { + @Override + RemoteStatus execute() throws Exception { + return getMusicService().stopJukebox(downloadService, null); + } + } + + private class Start extends RemoteTask { + @Override + RemoteStatus execute() throws Exception { + return getMusicService().startJukebox(downloadService, null); + } + } + + private class SetGain extends RemoteTask { + private final float gain; + + private SetGain(float gain) { + this.gain = gain; + } + + @Override + RemoteStatus execute() throws Exception { + return getMusicService().setJukeboxGain(gain, downloadService, null); + } + } + + private class ShutdownTask extends RemoteTask { + @Override + RemoteStatus execute() throws Exception { + return null; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/MediaStoreService.java b/app/src/main/java/github/daneren2005/dsub/service/MediaStoreService.java new file mode 100644 index 00000000..0aa3269f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/MediaStoreService.java @@ -0,0 +1,187 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.io.File; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.Log; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + */ +public class MediaStoreService { + + private static final String TAG = MediaStoreService.class.getSimpleName(); + private static final Uri ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart"); + + private final Context context; + + public MediaStoreService(Context context) { + this.context = context; + } + + public void saveInMediaStore(DownloadFile downloadFile) { + MusicDirectory.Entry song = downloadFile.getSong(); + File songFile = downloadFile.getCompleteFile(); + + // Delete existing row in case the song has been downloaded before. + deleteFromMediaStore(downloadFile); + + ContentResolver contentResolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + if(!song.isVideo()) { + values.put(MediaStore.MediaColumns.TITLE, song.getTitle()); + values.put(MediaStore.MediaColumns.DATA, songFile.getAbsolutePath()); + values.put(MediaStore.Audio.AudioColumns.ARTIST, song.getArtist()); + values.put(MediaStore.Audio.AudioColumns.ALBUM, song.getAlbum()); + if (song.getDuration() != null) { + values.put(MediaStore.Audio.AudioColumns.DURATION, song.getDuration() * 1000L); + } + if (song.getTrack() != null) { + values.put(MediaStore.Audio.AudioColumns.TRACK, song.getTrack()); + } + if (song.getYear() != null) { + values.put(MediaStore.Audio.AudioColumns.YEAR, song.getYear()); + } + if(song.getTranscodedContentType() != null) { + values.put(MediaStore.MediaColumns.MIME_TYPE, song.getTranscodedContentType()); + } else { + values.put(MediaStore.MediaColumns.MIME_TYPE, song.getContentType()); + } + values.put(MediaStore.Audio.AudioColumns.IS_MUSIC, 1); + + Uri uri = contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values); + + // Look up album, and add cover art if found. + Cursor cursor = contentResolver.query(uri, new String[]{MediaStore.Audio.AudioColumns.ALBUM_ID}, null, null, null); + if (cursor.moveToFirst()) { + int albumId = cursor.getInt(0); + insertAlbumArt(albumId, downloadFile); + } + + cursor.close(); + } else { + values.put(MediaStore.Video.VideoColumns.TITLE, song.getTitle()); + values.put(MediaStore.Video.VideoColumns.DISPLAY_NAME, song.getTitle()); + values.put(MediaStore.Video.VideoColumns.ARTIST, song.getArtist()); + values.put(MediaStore.Video.VideoColumns.DATA, songFile.getAbsolutePath()); + if (song.getDuration() != null) { + values.put(MediaStore.Video.VideoColumns.DURATION, song.getDuration() * 1000L); + } + + String videoPlayerType = Util.getVideoPlayerType(context); + if("hls".equals(videoPlayerType)) { + // HLS should be able to transcode to mp4 automatically + values.put(MediaStore.MediaColumns.MIME_TYPE, "video/mpeg"); + } else if("raw".equals(videoPlayerType) || song.getTranscodedContentType() == null) { + // Download the original video without any transcoding + values.put(MediaStore.MediaColumns.MIME_TYPE, song.getContentType()); + } else { + values.put(MediaStore.MediaColumns.MIME_TYPE, song.getTranscodedContentType()); + } + + Uri uri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values); + if(uri == null) { + Log.e(TAG, "Failed to insert"); + } + } + } + + public void deleteFromMediaStore(DownloadFile downloadFile) { + ContentResolver contentResolver = context.getContentResolver(); + MusicDirectory.Entry song = downloadFile.getSong(); + File file = downloadFile.getCompleteFile(); + + Uri uri; + if(song.isVideo()) { + uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else { + uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + int n = contentResolver.delete(uri, + MediaStore.MediaColumns.DATA + "=?", + new String[]{file.getAbsolutePath()}); + if (n > 0) { + Log.i(TAG, "Deleting media store row for " + song); + } + } + + public void deleteFromMediaStore(File file) { + ContentResolver contentResolver = context.getContentResolver(); + + Uri uri; + if(FileUtil.isVideoFile(file)) { + uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else { + uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + int n = contentResolver.delete(uri, + MediaStore.MediaColumns.DATA + "=?", + new String[]{file.getAbsolutePath()}); + if (n > 0) { + Log.i(TAG, "Deleting media store row for " + file); + } + } + + public void renameInMediaStore(File start, File end) { + ContentResolver contentResolver = context.getContentResolver(); + + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATA, end.getAbsolutePath()); + + int n = contentResolver.update(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + values, + MediaStore.MediaColumns.DATA + "=?", + new String[]{start.getAbsolutePath()}); + if (n > 0) { + Log.i(TAG, "Rename media store row for " + start + " to " + end); + } + } + + private void insertAlbumArt(int albumId, DownloadFile downloadFile) { + ContentResolver contentResolver = context.getContentResolver(); + + Cursor cursor = contentResolver.query(Uri.withAppendedPath(ALBUM_ART_URI, String.valueOf(albumId)), null, null, null, null); + if (!cursor.moveToFirst()) { + + // No album art found, add it. + File albumArtFile = FileUtil.getAlbumArtFile(context, downloadFile.getSong()); + if (albumArtFile.exists()) { + ContentValues values = new ContentValues(); + values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, albumId); + values.put(MediaStore.MediaColumns.DATA, albumArtFile.getPath()); + contentResolver.insert(ALBUM_ART_URI, values); + Log.i(TAG, "Added album art: " + albumArtFile); + } + } + cursor.close(); + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/MusicService.java b/app/src/main/java/github/daneren2005/dsub/service/MusicService.java new file mode 100644 index 00000000..4d014462 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/MusicService.java @@ -0,0 +1,197 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.util.List; + +import org.apache.http.HttpResponse; + +import android.content.Context; +import android.graphics.Bitmap; + +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.ChatMessage; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.domain.RemoteStatus; +import github.daneren2005.dsub.domain.Lyrics; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.domain.SearchCritera; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.domain.Version; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.ProgressListener; + +/** + * @author Sindre Mehus + */ +public interface MusicService { + + void ping(Context context, ProgressListener progressListener) throws Exception; + + boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception; + + List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + void startRescan(Context context, ProgressListener listener) throws Exception; + + Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getMusicDirectory(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getArtist(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getAlbum(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getStarredList(Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getPlaylist(boolean refresh, String id, String name, Context context, ProgressListener progressListener) throws Exception; + + List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception; + + void deletePlaylist(String id, Context context, ProgressListener progressListener) throws Exception; + + void addToPlaylist(String id, List toAdd, Context context, ProgressListener progressListener) throws Exception; + + void removeFromPlaylist(String id, List toRemove, Context context, ProgressListener progressListener) throws Exception; + + void overwritePlaylist(String id, String name, int toRemove, List toAdd, Context context, ProgressListener progressListener) throws Exception; + + void updatePlaylist(String id, String name, String comment, boolean pub, Context context, ProgressListener progressListener) throws Exception; + + Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception; + + void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getAlbumList(String type, String extra, int size, int offset, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getRandomSongs(int size, String artistId, Context context, ProgressListener progressListener) throws Exception; + MusicDirectory getRandomSongs(int size, String folder, String genre, String startYear, String endYear, Context context, ProgressListener progressListener) throws Exception; + + String getCoverArtUrl(Context context, MusicDirectory.Entry entry) throws Exception; + + Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, ProgressListener progressListener, SilentBackgroundTask task) throws Exception; + + HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, SilentBackgroundTask task) throws Exception; + + String getMusicUrl(Context context, MusicDirectory.Entry song, int maxBitrate) throws Exception; + + String getVideoUrl(int maxBitrate, Context context, String id); + + String getVideoStreamUrl(String format, int Bitrate, Context context, String id) throws Exception; + + String getHlsUrl(String id, int bitRate, Context context) throws Exception; + + RemoteStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception; + + RemoteStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception; + + RemoteStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception; + + RemoteStatus startJukebox(Context context, ProgressListener progressListener) throws Exception; + + RemoteStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception; + + RemoteStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception; + + void setStarred(List entries, List artists, List albums, boolean starred, ProgressListener progressListener, Context context) throws Exception; + + List getShares(Context context, ProgressListener progressListener) throws Exception; + + List createShare(List ids, String description, Long expires, Context context, ProgressListener progressListener) throws Exception; + + void deleteShare(String id, Context context, ProgressListener progressListener) throws Exception; + + void updateShare(String id, String description, Long expires, Context context, ProgressListener progressListener) throws Exception; + + List getChatMessages(Long since, Context context, ProgressListener progressListener) throws Exception; + + void addChatMessage(String message, Context context, ProgressListener progressListener) throws Exception; + + List getGenres(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getSongsByGenre(String genre, int count, int offset, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getTopTrackSongs(String artist, int size, Context context, ProgressListener progressListener) throws Exception; + + List getPodcastChannels(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getPodcastEpisodes(boolean refresh, String id, Context context, ProgressListener progressListener) throws Exception; + + void refreshPodcasts(Context context, ProgressListener progressListener) throws Exception; + + void createPodcastChannel(String url, Context context, ProgressListener progressListener) throws Exception; + + void deletePodcastChannel(String id, Context context, ProgressListener progressListener) throws Exception; + + void downloadPodcastEpisode(String id, Context context, ProgressListener progressListener) throws Exception; + + void deletePodcastEpisode(String id, String parent, ProgressListener progressListener, Context context) throws Exception; + + void setRating(MusicDirectory.Entry entry, int rating, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getBookmarks(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + void createBookmark(MusicDirectory.Entry entry, int position, String comment, Context context, ProgressListener progressListener) throws Exception; + + void deleteBookmark(MusicDirectory.Entry entry, Context context, ProgressListener progressListener) throws Exception; + + User getUser(boolean refresh, String username, Context context, ProgressListener progressListener) throws Exception; + + List getUsers(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + void createUser(User user, Context context, ProgressListener progressListener) throws Exception; + + void updateUser(User user, Context context, ProgressListener progressListener) throws Exception; + + void deleteUser(String username, Context context, ProgressListener progressListener) throws Exception; + + void changeEmail(String username, String email, Context context, ProgressListener progressListener) throws Exception; + + void changePassword(String username, String password, Context context, ProgressListener progressListener) throws Exception; + + Bitmap getAvatar(String username, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception; + + ArtistInfo getArtistInfo(String id, boolean refresh, boolean allowNetwork, Context context, ProgressListener progressListener) throws Exception; + + Bitmap getBitmap(String url, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception; + + MusicDirectory getVideos(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + void savePlayQueue(List songs, MusicDirectory.Entry currentPlaying, int position, Context context, ProgressListener progressListener) throws Exception; + + PlayerQueue getPlayQueue(Context context, ProgressListener progressListener) throws Exception; + + int processOfflineSyncs(final Context context, final ProgressListener progressListener) throws Exception; + + void setInstance(Integer instance) throws Exception; +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/MusicServiceFactory.java b/app/src/main/java/github/daneren2005/dsub/service/MusicServiceFactory.java new file mode 100644 index 00000000..e04522ff --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/MusicServiceFactory.java @@ -0,0 +1,36 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import android.content.Context; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class MusicServiceFactory { + + private static final MusicService REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService()); + private static final MusicService OFFLINE_MUSIC_SERVICE = new OfflineMusicService(); + + public static MusicService getMusicService(Context context) { + return Util.isOffline(context) ? OFFLINE_MUSIC_SERVICE : REST_MUSIC_SERVICE; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/OfflineException.java b/app/src/main/java/github/daneren2005/dsub/service/OfflineException.java new file mode 100644 index 00000000..e3a8d460 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/OfflineException.java @@ -0,0 +1,32 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +/** + * Thrown by service methods that are not available in offline mode. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class OfflineException extends Exception { + + public OfflineException(String message) { + super(message); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/OfflineMusicService.java b/app/src/main/java/github/daneren2005/dsub/service/OfflineMusicService.java new file mode 100644 index 00000000..b4105d07 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/OfflineMusicService.java @@ -0,0 +1,836 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.io.File; +import java.io.Reader; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.util.Log; + +import org.apache.http.HttpResponse; + +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.ChatMessage; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.domain.RemoteStatus; +import github.daneren2005.dsub.domain.Lyrics; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.domain.SearchCritera; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Util; +import java.io.*; +import java.util.Comparator; +import java.util.SortedSet; + +/** + * @author Sindre Mehus + */ +public class OfflineMusicService implements MusicService { + private static final String TAG = OfflineMusicService.class.getSimpleName(); + private static final String ERRORMSG = "Not available in offline mode"; + private static final Random random = new Random(); + + @Override + public void ping(Context context, ProgressListener progressListener) throws Exception { + + } + + @Override + public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception { + return true; + } + + @Override + public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List artists = new ArrayList(); + File root = FileUtil.getMusicDirectory(context); + for (File file : FileUtil.listFiles(root)) { + if (file.isDirectory()) { + Artist artist = new Artist(); + artist.setId(file.getPath()); + artist.setIndex(file.getName().substring(0, 1)); + artist.setName(file.getName()); + artists.add(artist); + } + } + + Indexes indexes = new Indexes(0L, Collections.emptyList(), artists); + indexes.sortChildren(context); + return indexes; + } + + @Override + public MusicDirectory getMusicDirectory(String id, String artistName, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + return getMusicDirectory(id, artistName, refresh, context, progressListener, false); + } + private MusicDirectory getMusicDirectory(String id, String artistName, boolean refresh, Context context, ProgressListener progressListener, boolean isPodcast) throws Exception { + File dir = new File(id); + MusicDirectory result = new MusicDirectory(); + result.setName(dir.getName()); + + Set names = new HashSet(); + + for (File file : FileUtil.listMediaFiles(dir)) { + String name = getName(file); + if (name != null & !names.contains(name)) { + names.add(name); + result.addChild(createEntry(context, file, name, true, isPodcast)); + } + } + result.sortChildren(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_CUSTOM_SORT_ENABLED, true)); + return result; + } + + @Override + public MusicDirectory getArtist(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getAlbum(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + private String getName(File file) { + String name = file.getName(); + if (file.isDirectory()) { + return name; + } + + if (name.endsWith(".partial") || name.contains(".partial.") || name.equals(Constants.ALBUM_ART_FILE)) { + return null; + } + + name = name.replace(".complete", ""); + return FileUtil.getBaseName(name); + } + + private MusicDirectory.Entry createEntry(Context context, File file, String name) { + return createEntry(context, file, name, true); + } + private MusicDirectory.Entry createEntry(Context context, File file, String name, boolean load) { + return createEntry(context, file, name, load, false); + } + private MusicDirectory.Entry createEntry(Context context, File file, String name, boolean load, boolean isPodcast) { + MusicDirectory.Entry entry; + if(isPodcast) { + PodcastEpisode episode = new PodcastEpisode(); + episode.setStatus("completed"); + entry = episode; + } else { + entry = new MusicDirectory.Entry(); + } + entry.setDirectory(file.isDirectory()); + entry.setId(file.getPath()); + entry.setParent(file.getParent()); + entry.setSize(file.length()); + String root = FileUtil.getMusicDirectory(context).getPath(); + if(!file.getParentFile().getParentFile().getPath().equals(root)) { + entry.setGrandParent(file.getParentFile().getParent()); + } + entry.setPath(file.getPath().replaceFirst("^" + root + "/" , "")); + String title = name; + if (file.isFile()) { + File artistFolder = file.getParentFile().getParentFile(); + File albumFolder = file.getParentFile(); + if(artistFolder.getPath().equals(root)) { + entry.setArtist(albumFolder.getName()); + } else { + entry.setArtist(artistFolder.getName()); + } + entry.setAlbum(albumFolder.getName()); + + int index = name.indexOf('-'); + if(index != -1) { + try { + entry.setTrack(Integer.parseInt(name.substring(0, index))); + title = title.substring(index + 1); + } catch(Exception e) { + // Failed parseInt, just means track filled out + } + } + + if(load) { + entry.loadMetadata(file); + } + } + + entry.setTitle(title); + entry.setSuffix(FileUtil.getExtension(file.getName().replace(".complete", ""))); + + File albumArt = FileUtil.getAlbumArtFile(context, entry); + if (albumArt.exists()) { + entry.setCoverArt(albumArt.getPath()); + } + if(FileUtil.isVideoFile(file)) { + entry.setVideo(true); + } + return entry; + } + + @Override + public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + try { + return FileUtil.getAlbumArtBitmap(context, entry, size); + } catch(Exception e) { + return null; + } + } + + @Override + public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, SilentBackgroundTask task) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public String getMusicUrl(Context context, MusicDirectory.Entry song, int maxBitrate) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void startRescan(Context context, ProgressListener listener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception { + List artists = new ArrayList(); + List albums = new ArrayList(); + List songs = new ArrayList(); + File root = FileUtil.getMusicDirectory(context); + int closeness = 0; + for (File artistFile : FileUtil.listFiles(root)) { + String artistName = artistFile.getName(); + if (artistFile.isDirectory()) { + if((closeness = matchCriteria(criteria, artistName)) > 0) { + Artist artist = new Artist(); + artist.setId(artistFile.getPath()); + artist.setIndex(artistFile.getName().substring(0, 1)); + artist.setName(artistName); + artist.setCloseness(closeness); + artists.add(artist); + } + + recursiveAlbumSearch(artistName, artistFile, criteria, context, albums, songs); + } + } + + Collections.sort(artists, new Comparator() { + public int compare(Artist lhs, Artist rhs) { + if(lhs.getCloseness() == rhs.getCloseness()) { + return 0; + } + else if(lhs.getCloseness() > rhs.getCloseness()) { + return -1; + } + else { + return 1; + } + } + }); + Collections.sort(albums, new Comparator() { + public int compare(MusicDirectory.Entry lhs, MusicDirectory.Entry rhs) { + if(lhs.getCloseness() == rhs.getCloseness()) { + return 0; + } + else if(lhs.getCloseness() > rhs.getCloseness()) { + return -1; + } + else { + return 1; + } + } + }); + Collections.sort(songs, new Comparator() { + public int compare(MusicDirectory.Entry lhs, MusicDirectory.Entry rhs) { + if(lhs.getCloseness() == rhs.getCloseness()) { + return 0; + } + else if(lhs.getCloseness() > rhs.getCloseness()) { + return -1; + } + else { + return 1; + } + } + }); + + return new SearchResult(artists, albums, songs); + } + + @Override + public MusicDirectory getStarredList(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + private void recursiveAlbumSearch(String artistName, File file, SearchCritera criteria, Context context, List albums, List songs) { + int closeness; + for(File albumFile : FileUtil.listMediaFiles(file)) { + if(albumFile.isDirectory()) { + String albumName = getName(albumFile); + if((closeness = matchCriteria(criteria, albumName)) > 0) { + MusicDirectory.Entry album = createEntry(context, albumFile, albumName); + album.setArtist(artistName); + album.setCloseness(closeness); + albums.add(album); + } + + for(File songFile : FileUtil.listMediaFiles(albumFile)) { + String songName = getName(songFile); + if(songName == null) { + continue; + } + + if(songFile.isDirectory()) { + recursiveAlbumSearch(artistName, songFile, criteria, context, albums, songs); + } + else if((closeness = matchCriteria(criteria, songName)) > 0){ + MusicDirectory.Entry song = createEntry(context, albumFile, songName); + song.setArtist(artistName); + song.setAlbum(albumName); + song.setCloseness(closeness); + songs.add(song); + } + } + } + else { + String songName = getName(albumFile); + if((closeness = matchCriteria(criteria, songName)) > 0) { + MusicDirectory.Entry song = createEntry(context, albumFile, songName); + song.setArtist(artistName); + song.setAlbum(songName); + song.setCloseness(closeness); + songs.add(song); + } + } + } + } + private int matchCriteria(SearchCritera criteria, String name) { + String query = criteria.getQuery().toLowerCase(); + String[] queryParts = query.split(" "); + String[] nameParts = name.toLowerCase().split(" "); + + int closeness = 0; + for(String queryPart : queryParts) { + for(String namePart : nameParts) { + if(namePart.equals(queryPart)) { + closeness++; + } + } + } + + return closeness; + } + + @Override + public List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List playlists = new ArrayList(); + File root = FileUtil.getPlaylistDirectory(context); + String lastServer = null; + boolean removeServer = true; + for (File folder : FileUtil.listFiles(root)) { + if(folder.isDirectory()) { + String server = folder.getName(); + SortedSet fileList = FileUtil.listFiles(folder); + for(File file: fileList) { + if(FileUtil.isPlaylistFile(file)) { + String id = file.getName(); + String filename = server + ": " + FileUtil.getBaseName(id); + Playlist playlist = new Playlist(server, filename); + playlists.add(playlist); + } + } + + if(!server.equals(lastServer) && fileList.size() > 0) { + if(lastServer != null) { + removeServer = false; + } + lastServer = server; + } + } else { + // Delete legacy playlist files + try { + folder.delete(); + } catch(Exception e) { + Log.w(TAG, "Failed to delete old playlist file: " + folder.getName()); + } + } + } + + if(removeServer) { + for(Playlist playlist: playlists) { + playlist.setName(playlist.getName().substring(playlist.getId().length() + 2)); + } + } + return playlists; + } + + @Override + public MusicDirectory getPlaylist(boolean refresh, String id, String name, Context context, ProgressListener progressListener) throws Exception { + DownloadService downloadService = DownloadService.getInstance(); + if (downloadService == null) { + return new MusicDirectory(); + } + + Reader reader = null; + BufferedReader buffer = null; + try { + int firstIndex = name.indexOf(id); + if(firstIndex != -1) { + name = name.substring(id.length() + 2); + } + + File playlistFile = FileUtil.getPlaylistFile(context, id, name); + reader = new FileReader(playlistFile); + buffer = new BufferedReader(reader); + + MusicDirectory playlist = new MusicDirectory(); + String line = buffer.readLine(); + if(!"#EXTM3U".equals(line)) return playlist; + + while( (line = buffer.readLine()) != null ){ + // No matter what, end file can't have .complete in it + line = line.replace(".complete", ""); + File entryFile = new File(line); + + // Don't add file to playlist if it doesn't exist as cached or pinned! + File checkFile = entryFile; + if(!checkFile.exists()) { + // If normal file doens't exist, check if .complete version does + checkFile = new File(entryFile.getParent(), FileUtil.getBaseName(entryFile.getName()) + + ".complete." + FileUtil.getExtension(entryFile.getName())); + } + + String entryName = getName(entryFile); + if(checkFile.exists() && entryName != null){ + playlist.addChild(createEntry(context, entryFile, entryName, false)); + } + } + + return playlist; + } finally { + Util.close(buffer); + Util.close(reader); + } + } + + @Override + public void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void deletePlaylist(String id, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void addToPlaylist(String id, List toAdd, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void removeFromPlaylist(String id, List toRemove, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void overwritePlaylist(String id, String name, int toRemove, List toAdd, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void updatePlaylist(String id, String name, String comment, boolean pub, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception { + if(!submission) { + return; + } + + SharedPreferences prefs = Util.getPreferences(context); + String cacheLocn = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + + SharedPreferences offline = Util.getOfflineSync(context); + int scrobbles = offline.getInt(Constants.OFFLINE_SCROBBLE_COUNT, 0); + scrobbles++; + SharedPreferences.Editor offlineEditor = offline.edit(); + + if(id.indexOf(cacheLocn) != -1) { + String scrobbleSearchCriteria = Util.parseOfflineIDSearch(context, id, cacheLocn); + offlineEditor.putString(Constants.OFFLINE_SCROBBLE_SEARCH + scrobbles, scrobbleSearchCriteria); + offlineEditor.remove(Constants.OFFLINE_SCROBBLE_ID + scrobbles); + } else { + offlineEditor.putString(Constants.OFFLINE_SCROBBLE_ID + scrobbles, id); + offlineEditor.remove(Constants.OFFLINE_SCROBBLE_SEARCH + scrobbles); + } + + offlineEditor.putLong(Constants.OFFLINE_SCROBBLE_TIME + scrobbles, System.currentTimeMillis()); + offlineEditor.putInt(Constants.OFFLINE_SCROBBLE_COUNT, scrobbles); + offlineEditor.commit(); + } + + @Override + public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getAlbumList(String type, String extra, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getRandomSongs(int size, String artistId, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public String getVideoUrl(int maxBitrate, Context context, String id) { + return null; + } + + @Override + public String getVideoStreamUrl(String format, int maxBitrate, Context context, String id) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public String getHlsUrl(String id, int bitRate, Context context) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public RemoteStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public RemoteStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public RemoteStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public RemoteStatus startJukebox(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public RemoteStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public RemoteStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void setStarred(List entries, List artists, List albums, boolean starred, ProgressListener progressListener, Context context) throws Exception { + SharedPreferences prefs = Util.getPreferences(context); + String cacheLocn = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + + SharedPreferences offline = Util.getOfflineSync(context); + int stars = offline.getInt(Constants.OFFLINE_STAR_COUNT, 0); + stars++; + SharedPreferences.Editor offlineEditor = offline.edit(); + + String id = entries.get(0).getId(); + if(id.indexOf(cacheLocn) != -1) { + String searchCriteria = Util.parseOfflineIDSearch(context, id, cacheLocn); + offlineEditor.putString(Constants.OFFLINE_STAR_SEARCH + stars, searchCriteria); + offlineEditor.remove(Constants.OFFLINE_STAR_ID + stars); + } else { + offlineEditor.putString(Constants.OFFLINE_STAR_ID + stars, id); + offlineEditor.remove(Constants.OFFLINE_STAR_SEARCH + stars); + } + + offlineEditor.putBoolean(Constants.OFFLINE_STAR_SETTING + stars, starred); + offlineEditor.putInt(Constants.OFFLINE_STAR_COUNT, stars); + offlineEditor.commit(); + } + + @Override + public List getShares(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public List createShare(List ids, String description, Long expires, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void deleteShare(String id, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void updateShare(String id, String description, Long expires, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public List getChatMessages(Long since, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void addChatMessage(String message, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public List getGenres(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getSongsByGenre(String genre, int count, int offset, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getTopTrackSongs(String artist, int size, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getRandomSongs(int size, String folder, String genre, String startYear, String endYear, Context context, ProgressListener progressListener) throws Exception { + File root = FileUtil.getMusicDirectory(context); + List children = new LinkedList(); + listFilesRecursively(root, children); + MusicDirectory result = new MusicDirectory(); + + if (children.isEmpty()) { + return result; + } + for (int i = 0; i < size; i++) { + File file = children.get(random.nextInt(children.size())); + result.addChild(createEntry(context, file, getName(file))); + } + + return result; + } + + @Override + public String getCoverArtUrl(Context context, MusicDirectory.Entry entry) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public List getPodcastChannels(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List channels = new ArrayList(); + + File dir = FileUtil.getPodcastDirectory(context); + String line; + for(File file: dir.listFiles()) { + BufferedReader br = new BufferedReader(new FileReader(file)); + while ((line = br.readLine()) != null && !"".equals(line)) { + PodcastChannel channel = new PodcastChannel(); + channel.setId(line); + channel.setName(line); + channel.setStatus("completed"); + + if(FileUtil.getPodcastDirectory(context, channel).exists() && !channels.contains(channel)) { + channels.add(channel); + } + } + br.close(); + } + + return channels; + } + + @Override + public MusicDirectory getPodcastEpisodes(boolean refresh, String id, Context context, ProgressListener progressListener) throws Exception { + return getMusicDirectory(FileUtil.getPodcastDirectory(context, id).getPath(), null, false, context, progressListener, true); + } + + @Override + public void refreshPodcasts(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void createPodcastChannel(String url, Context context, ProgressListener progressListener) throws Exception{ + throw new OfflineException(ERRORMSG); + } + + @Override + public void deletePodcastChannel(String id, Context context, ProgressListener progressListener) throws Exception{ + throw new OfflineException(ERRORMSG); + } + + @Override + public void downloadPodcastEpisode(String id, Context context, ProgressListener progressListener) throws Exception{ + throw new OfflineException(ERRORMSG); + } + + @Override + public void deletePodcastEpisode(String id, String parent, ProgressListener progressListener, Context context) throws Exception{ + throw new OfflineException(ERRORMSG); + } + + @Override + public void setRating(MusicDirectory.Entry entry, int rating, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getBookmarks(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void createBookmark(MusicDirectory.Entry entry, int position, String comment, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void deleteBookmark(MusicDirectory.Entry entry, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public User getUser(boolean refresh, String username, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public List getUsers(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void createUser(User user, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void updateUser(User user, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void deleteUser(String username, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void changeEmail(String username, String email, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void changePassword(String username, String password, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public Bitmap getAvatar(String username, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public ArtistInfo getArtistInfo(String id, boolean refresh, boolean allowNetwork, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public Bitmap getBitmap(String url, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public MusicDirectory getVideos(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public void savePlayQueue(List songs, MusicDirectory.Entry currentPlaying, int position, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public PlayerQueue getPlayQueue(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException(ERRORMSG); + } + + @Override + public int processOfflineSyncs(final Context context, final ProgressListener progressListener) throws Exception{ + throw new OfflineException(ERRORMSG); + } + + @Override + public void setInstance(Integer instance) throws Exception{ + throw new OfflineException(ERRORMSG); + } + + private void listFilesRecursively(File parent, List children) { + for (File file : FileUtil.listMediaFiles(parent)) { + if (file.isFile()) { + children.add(file); + } else { + listFilesRecursively(file, children); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/RESTMusicService.java b/app/src/main/java/github/daneren2005/dsub/service/RESTMusicService.java new file mode 100644 index 00000000..459c8c9e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/RESTMusicService.java @@ -0,0 +1,1991 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.HttpClient; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.conn.params.ConnManagerParams; +import org.apache.http.conn.params.ConnPerRouteBean; +import org.apache.http.conn.scheme.PlainSocketFactory; +import org.apache.http.conn.scheme.Scheme; +import org.apache.http.conn.scheme.SchemeRegistry; +import org.apache.http.conn.scheme.SocketFactory; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; +import org.apache.http.message.BasicHeader; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; +import org.apache.http.protocol.BasicHttpContext; +import org.apache.http.protocol.ExecutionContext; +import org.apache.http.protocol.HttpContext; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Looper; +import android.util.Log; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.*; +import github.daneren2005.dsub.service.parser.AlbumListParser; +import github.daneren2005.dsub.service.parser.ArtistInfoParser; +import github.daneren2005.dsub.service.parser.BookmarkParser; +import github.daneren2005.dsub.service.parser.ChatMessageParser; +import github.daneren2005.dsub.service.parser.ErrorParser; +import github.daneren2005.dsub.service.parser.GenreParser; +import github.daneren2005.dsub.service.parser.IndexesParser; +import github.daneren2005.dsub.service.parser.JukeboxStatusParser; +import github.daneren2005.dsub.service.parser.LicenseParser; +import github.daneren2005.dsub.service.parser.LyricsParser; +import github.daneren2005.dsub.service.parser.MusicDirectoryParser; +import github.daneren2005.dsub.service.parser.MusicFoldersParser; +import github.daneren2005.dsub.service.parser.PlayQueueParser; +import github.daneren2005.dsub.service.parser.PlaylistParser; +import github.daneren2005.dsub.service.parser.PlaylistsParser; +import github.daneren2005.dsub.service.parser.PodcastChannelParser; +import github.daneren2005.dsub.service.parser.PodcastEntryParser; +import github.daneren2005.dsub.service.parser.RandomSongsParser; +import github.daneren2005.dsub.service.parser.ScanStatusParser; +import github.daneren2005.dsub.service.parser.SearchResult2Parser; +import github.daneren2005.dsub.service.parser.SearchResultParser; +import github.daneren2005.dsub.service.parser.ShareParser; +import github.daneren2005.dsub.service.parser.StarredListParser; +import github.daneren2005.dsub.service.parser.UserParser; +import github.daneren2005.dsub.service.parser.VideosParser; +import github.daneren2005.dsub.service.ssl.SSLSocketFactory; +import github.daneren2005.dsub.service.ssl.TrustSelfSignedStrategy; +import github.daneren2005.dsub.util.BackgroundTask; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; +import java.io.*; +import java.util.zip.GZIPInputStream; + +/** + * @author Sindre Mehus + */ +public class RESTMusicService implements MusicService { + + private static final String TAG = RESTMusicService.class.getSimpleName(); + + private static final int SOCKET_CONNECT_TIMEOUT = 10 * 1000; + private static final int SOCKET_READ_TIMEOUT_DEFAULT = 10 * 1000; + private static final int SOCKET_READ_TIMEOUT_DOWNLOAD = 30 * 1000; + private static final int SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS = 60 * 1000; + private static final int SOCKET_READ_TIMEOUT_GET_PLAYLIST = 60 * 1000; + + // Allow 20 seconds extra timeout per MB offset. + private static final double TIMEOUT_MILLIS_PER_OFFSET_BYTE = 20000.0 / 1000000.0; + + private static final int HTTP_REQUEST_MAX_ATTEMPTS = 5; + private static final long REDIRECTION_CHECK_INTERVAL_MILLIS = 60L * 60L * 1000L; + + private final DefaultHttpClient httpClient; + private long redirectionLastChecked; + private int redirectionNetworkType = -1; + private String redirectFrom; + private String redirectTo; + private final ThreadSafeClientConnManager connManager; + private Integer instance; + + public RESTMusicService() { + + // Create and initialize default HTTP parameters + HttpParams params = new BasicHttpParams(); + ConnManagerParams.setMaxTotalConnections(params, 20); + ConnManagerParams.setMaxConnectionsPerRoute(params, new ConnPerRouteBean(20)); + HttpConnectionParams.setConnectionTimeout(params, SOCKET_CONNECT_TIMEOUT); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_DEFAULT); + + // Turn off stale checking. Our connections break all the time anyway, + // and it's not worth it to pay the penalty of checking every time. + HttpConnectionParams.setStaleCheckingEnabled(params, false); + + // Create and initialize scheme registry + SchemeRegistry schemeRegistry = new SchemeRegistry(); + schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); + schemeRegistry.register(new Scheme("https", createSSLSocketFactory(), 443)); + + // Create an HttpClient with the ThreadSafeClientConnManager. + // This connection manager must be used if more than one thread will + // be using the HttpClient. + connManager = new ThreadSafeClientConnManager(params, schemeRegistry); + httpClient = new DefaultHttpClient(connManager, params); + } + + private SocketFactory createSSLSocketFactory() { + try { + return new SSLSocketFactory(new TrustSelfSignedStrategy(), SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER); + } catch (Throwable x) { + Log.e(TAG, "Failed to create custom SSL socket factory, using default.", x); + return org.apache.http.conn.ssl.SSLSocketFactory.getSocketFactory(); + } + } + + @Override + public void ping(Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "ping", null); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception { + + Reader reader = getReader(context, progressListener, "getLicense", null); + try { + ServerInfo serverInfo = new LicenseParser(context, getInstance(context)).parse(reader); + return serverInfo.isLicenseValid(); + } finally { + Util.close(reader); + } + } + + public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getMusicFolders", null); + try { + return new MusicFoldersParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void startRescan(Context context, ProgressListener listener) throws Exception { + Reader reader = getReader(context, listener, "startRescan", null); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + + // Now check if still running + boolean done = false; + while(!done) { + reader = getReader(context, null, "scanstatus", null); + try { + boolean running = new ScanStatusParser(context, getInstance(context)).parse(reader, listener); + if(running) { + // Don't run system ragged trying to query too much + Thread.sleep(100L); + } else { + done = true; + } + } catch(Exception e) { + done = true; + } finally { + Util.close(reader); + } + } + } + + @Override + public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + if (musicFolderId != null) { + parameterNames.add("musicFolderId"); + parameterValues.add(musicFolderId); + } + + Reader reader = getReader(context, progressListener, Util.isTagBrowsing(context, getInstance(context)) ? "getArtists" : "getIndexes", null, parameterNames, parameterValues); + try { + return new IndexesParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getMusicDirectory(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + SharedPreferences prefs = Util.getPreferences(context); + String cacheLocn = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + if(cacheLocn != null && id.indexOf(cacheLocn) != -1) { + String search = Util.parseOfflineIDSearch(context, id, cacheLocn); + SearchCritera critera = new SearchCritera(search, 1, 1, 0); + SearchResult result = searchNew(critera, context, progressListener); + if(result.getArtists().size() == 1) { + id = result.getArtists().get(0).getId(); + } else if(result.getAlbums().size() == 1) { + id = result.getAlbums().get(0).getId(); + } + } + + MusicDirectory dir = null; + int index, start = 0; + while((index = id.indexOf(';', start)) != -1) { + MusicDirectory extra = getMusicDirectoryImpl(id.substring(start, index), name, refresh, context, progressListener); + if(dir == null) { + dir = extra; + } else { + dir.addChildren(extra.getChildren()); + } + + start = index + 1; + } + MusicDirectory extra = getMusicDirectoryImpl(id.substring(start), name, refresh, context, progressListener); + if(dir == null) { + dir = extra; + } else { + dir.addChildren(extra.getChildren()); + } + + // Apply another sort if we are chaining several together + if(dir != extra) { + dir.sortChildren(context, getInstance(context)); + } + + return dir; + } + + private MusicDirectory getMusicDirectoryImpl(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getMusicDirectory", null, "id", id); + try { + return new MusicDirectoryParser(context, getInstance(context)).parse(name, reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getArtist(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getArtist", null, "id", id); + try { + return new MusicDirectoryParser(context, getInstance(context)).parse(name, reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getAlbum(String id, String name, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getAlbum", null, "id", id); + try { + return new MusicDirectoryParser(context, getInstance(context)).parse(name, reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public SearchResult search(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception { + try { + return searchNew(critera, context, progressListener); + } catch (ServerTooOldException x) { + // Ensure backward compatibility with REST 1.3. + return searchOld(critera, context, progressListener); + } + } + + /** + * Search using the "search" REST method. + */ + private SearchResult searchOld(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = Arrays.asList("any", "songCount"); + List parameterValues = Arrays.asList(critera.getQuery(), critera.getSongCount()); + Reader reader = getReader(context, progressListener, "search", null, parameterNames, parameterValues); + try { + return new SearchResultParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + /** + * Search using the "search2" REST method, available in 1.4.0 and later. + */ + private SearchResult searchNew(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.4", null); + + List parameterNames = Arrays.asList("query", "artistCount", "albumCount", "songCount"); + List parameterValues = Arrays.asList(critera.getQuery(), critera.getArtistCount(), + critera.getAlbumCount(), critera.getSongCount()); + Reader reader = getReader(context, progressListener, Util.isTagBrowsing(context, getInstance(context)) ? "search3" : "search2", null, parameterNames, parameterValues); + try { + return new SearchResult2Parser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getPlaylist(boolean refresh, String id, String name, Context context, ProgressListener progressListener) throws Exception { + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_PLAYLIST); + + Reader reader = getReader(context, progressListener, "getPlaylist", params, "id", id); + try { + return new PlaylistParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getPlaylists", null); + try { + return new PlaylistsParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = new LinkedList(); + List parameterValues = new LinkedList(); + + if (id != null) { + parameterNames.add("playlistId"); + parameterValues.add(id); + } + if (name != null) { + parameterNames.add("name"); + parameterValues.add(name); + } + for (MusicDirectory.Entry entry : entries) { + parameterNames.add("songId"); + parameterValues.add(getOfflineSongId(entry.getId(), context, progressListener)); + } + + Reader reader = getReader(context, progressListener, "createPlaylist", null, parameterNames, parameterValues); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void deletePlaylist(String id, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "deletePlaylist", null, "id", id); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void addToPlaylist(String id, List toAdd, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.8", "Updating playlists is not supported."); + List names = new ArrayList(); + List values = new ArrayList(); + names.add("playlistId"); + values.add(id); + for(MusicDirectory.Entry song: toAdd) { + names.add("songIdToAdd"); + values.add(getOfflineSongId(song.getId(), context, progressListener)); + } + Reader reader = getReader(context, progressListener, "updatePlaylist", null, names, values); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void removeFromPlaylist(String id, List toRemove, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.8", "Updating playlists is not supported."); + List names = new ArrayList(); + List values = new ArrayList(); + names.add("playlistId"); + values.add(id); + for(Integer song: toRemove) { + names.add("songIndexToRemove"); + values.add(song); + } + Reader reader = getReader(context, progressListener, "updatePlaylist", null, names, values); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void overwritePlaylist(String id, String name, int toRemove, List toAdd, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.8", "Updating playlists is not supported."); + List names = new ArrayList(); + List values = new ArrayList(); + names.add("playlistId"); + values.add(id); + names.add("name"); + values.add(name); + for(MusicDirectory.Entry song: toAdd) { + names.add("songIdToAdd"); + values.add(song.getId()); + } + for(int i = 0; i < toRemove; i++) { + names.add("songIndexToRemove"); + values.add(i); + } + Reader reader = getReader(context, progressListener, "updatePlaylist", null, names, values); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void updatePlaylist(String id, String name, String comment, boolean pub, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.8", "Updating playlists is not supported."); + Reader reader = getReader(context, progressListener, "updatePlaylist", null, Arrays.asList("playlistId", "name", "comment", "public"), Arrays.asList(id, name, comment, pub)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getLyrics", null, Arrays.asList("artist", "title"), Arrays.asList(artist, title)); + try { + return new LyricsParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception { + id = getOfflineSongId(id, context, progressListener); + scrobble(id, submission, 0, context, progressListener); + } + + public void scrobble(String id, boolean submission, long time, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.5", "Scrobbling not supported."); + Reader reader; + if(time > 0){ + checkServerVersion(context, "1.8", "Scrobbling with a time not supported."); + reader = getReader(context, progressListener, "scrobble", null, Arrays.asList("id", "submission", "time"), Arrays.asList(id, submission, time)); + } + else + reader = getReader(context, progressListener, "scrobble", null, Arrays.asList("id", "submission"), Arrays.asList(id, submission)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + List names = new ArrayList(); + List values = new ArrayList(); + + names.add("type"); + values.add(type); + names.add("size"); + values.add(size); + names.add("offset"); + values.add(offset); + + // Add folder if it was set and is non null + int instance = getInstance(context); + if(Util.getAlbumListsPerFolder(context, instance)) { + String folderId = Util.getSelectedMusicFolderId(context, instance); + if(folderId != null) { + names.add("musicFolderId"); + values.add(folderId); + } + } + + Reader reader = getReader(context, progressListener, Util.isTagBrowsing(context, getInstance(context)) ? "getAlbumList2" : "getAlbumList", + null, names, values, true); + try { + return new AlbumListParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getAlbumList(String type, String extra, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.10.1", "This type of album list is not supported"); + + List names = new ArrayList(); + List values = new ArrayList(); + + names.add("size"); + names.add("offset"); + + values.add(size); + values.add(offset); + + if("genres".equals(type)) { + names.add("type"); + values.add("byGenre"); + + names.add("genre"); + values.add(extra); + } else if("years".equals(type)) { + names.add("type"); + values.add("byYear"); + + names.add("fromYear"); + names.add("toYear"); + + int decade = Integer.parseInt(extra); + values.add(decade); + values.add(decade + 10); + } + + // Add folder if it was set and is non null + int instance = getInstance(context); + if(Util.getAlbumListsPerFolder(context, instance)) { + String folderId = Util.getSelectedMusicFolderId(context, instance); + if(folderId != null) { + names.add("musicFolderId"); + values.add(folderId); + } + } + + Reader reader = getReader(context, progressListener, Util.isTagBrowsing(context, instance) ? "getAlbumList2" : "getAlbumList", null, names, values, true); + try { + return new AlbumListParser(context, instance).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getRandomSongs(int size, String artistId, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.11", "Artist radio is not supported"); + + List names = new ArrayList(); + List values = new ArrayList(); + + names.add("id"); + names.add("count"); + + values.add(artistId); + values.add(size); + + int instance = getInstance(context); + String method; + if(ServerInfo.isMadsonic(context, instance)) { + method = "getPandoraSongs"; + } else { + if (Util.isTagBrowsing(context, instance)) { + method = "getSimilarSongs2"; + } else { + method = "getSimilarSongs"; + } + } + + Reader reader = getReader(context, progressListener, method, null, names, values); + try { + return new RandomSongsParser(context, instance).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getStarredList(Context context, ProgressListener progressListener) throws Exception { + List names = new ArrayList(); + List values = new ArrayList(); + + // Add folder if it was set and is non null + int instance = getInstance(context); + if(Util.getAlbumListsPerFolder(context, instance)) { + String folderId = Util.getSelectedMusicFolderId(context, instance); + if(folderId != null) { + names.add("musicFolderId"); + values.add(folderId); + } + } + + Reader reader = getReader(context, progressListener, Util.isTagBrowsing(context, instance) ? "getStarred2" : "getStarred", null, names, values, true); + try { + return new StarredListParser(context, instance).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getRandomSongs(int size, String musicFolderId, String genre, String startYear, String endYear, Context context, ProgressListener progressListener) throws Exception { + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + List names = new ArrayList(); + List values = new ArrayList(); + + names.add("size"); + values.add(size); + + if (musicFolderId != null && !"".equals(musicFolderId) && !Util.isTagBrowsing(context, getInstance(context))) { + names.add("musicFolderId"); + values.add(musicFolderId); + } + if(genre != null && !"".equals(genre)) { + names.add("genre"); + values.add(genre); + } + if(startYear != null && !"".equals(startYear)) { + names.add("fromYear"); + values.add(startYear); + } + if(endYear != null && !"".equals(endYear)) { + names.add("toYear"); + values.add(endYear); + } + + Reader reader = getReader(context, progressListener, "getRandomSongs", params, names, values); + try { + return new RandomSongsParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + private void checkServerVersion(Context context, String version, String text) throws ServerTooOldException { + Version serverVersion = ServerInfo.getServerVersion(context); + Version requiredVersion = new Version(version); + boolean ok = serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0; + + if (!ok) { + throw new ServerTooOldException(text, serverVersion, requiredVersion); + } + } + + @Override + public String getCoverArtUrl(Context context, MusicDirectory.Entry entry) throws Exception { + StringBuilder builder = new StringBuilder(getRestUrl(context, "getCoverArt")); + builder.append("&id=").append(entry.getCoverArt()); + String url = builder.toString(); + url = Util.replaceInternalUrl(context, url); + url = rewriteUrlWithRedirect(context, url); + return url; + } + + @Override + public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + + // Synchronize on the entry so that we don't download concurrently for the same song. + synchronized (entry) { + + // Use cached file, if existing. + Bitmap bitmap = FileUtil.getAlbumArtBitmap(context, entry, size); + if (bitmap != null) { + return bitmap; + } + + String url = getRestUrl(context, "getCoverArt"); + + InputStream in = null; + try { + List parameterNames = Arrays.asList("id"); + List parameterValues = Arrays.asList(entry.getCoverArt()); + HttpEntity entity = getEntityForURL(context, url, null, parameterNames, parameterValues, progressListener, task); + + in = entity.getContent(); + Header contentEncoding = entity.getContentEncoding(); + if (contentEncoding != null && contentEncoding.getValue().equalsIgnoreCase("gzip")) { + in = new GZIPInputStream(in); + } + + // If content type is XML, an error occured. Get it. + String contentType = Util.getContentType(entity); + if (contentType != null && (contentType.startsWith("text/xml") || contentType.startsWith("text/html"))) { + new ErrorParser(context, getInstance(context)).parse(new InputStreamReader(in, Constants.UTF_8)); + return null; // Never reached. + } + + byte[] bytes = Util.toByteArray(in); + + // Handle case where partial was downloaded before being cancelled + if(task != null && task.isCancelled()) { + return null; + } + + OutputStream out = null; + try { + out = new FileOutputStream(FileUtil.getAlbumArtFile(context, entry)); + out.write(bytes); + } finally { + Util.close(out); + } + + // Size == 0 -> only want to download + if(size == 0) { + return null; + } else { + return FileUtil.getSampledBitmap(bytes, size); + } + } finally { + Util.close(in); + } + } + } + + @Override + public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, SilentBackgroundTask task) throws Exception { + + String url = getRestUrl(context, "stream"); + + // Set socket read timeout. Note: The timeout increases as the offset gets larger. This is + // to avoid the thrashing effect seen when offset is combined with transcoding/downsampling on the server. + // In that case, the server uses a long time before sending any data, causing the client to time out. + HttpParams params = new BasicHttpParams(); + int timeout = (int) (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE); + HttpConnectionParams.setSoTimeout(params, timeout); + + // Add "Range" header if offset is given. + List
headers = new ArrayList
(); + if (offset > 0) { + headers.add(new BasicHeader("Range", "bytes=" + offset + "-")); + } + + List parameterNames = new ArrayList(); + parameterNames.add("id"); + parameterNames.add("maxBitRate"); + + List parameterValues = new ArrayList(); + parameterValues.add(song.getId()); + parameterValues.add(maxBitrate); + + // If video specify what format to download + if(song.isVideo()) { + String videoPlayerType = Util.getVideoPlayerType(context); + if("hls".equals(videoPlayerType)) { + // HLS should be able to transcode to mp4 automatically + parameterNames.add("format"); + parameterValues.add("mp4"); + + parameterNames.add("hls"); + parameterValues.add("true"); + } else if("raw".equals(videoPlayerType)) { + // Download the original video without any transcoding + parameterNames.add("format"); + parameterValues.add("raw"); + } + } + HttpResponse response = getResponseForURL(context, url, params, parameterNames, parameterValues, headers, null, task, false); + + // If content type is XML, an error occurred. Get it. + String contentType = Util.getContentType(response.getEntity()); + if (contentType != null && (contentType.startsWith("text/xml") || contentType.startsWith("text/html"))) { + InputStream in = response.getEntity().getContent(); + Header contentEncoding = response.getEntity().getContentEncoding(); + if (contentEncoding != null && contentEncoding.getValue().equalsIgnoreCase("gzip")) { + in = new GZIPInputStream(in); + } + try { + new ErrorParser(context, getInstance(context)).parse(new InputStreamReader(in, Constants.UTF_8)); + } finally { + Util.close(in); + } + } + + return response; + } + + @Override + public String getMusicUrl(Context context, MusicDirectory.Entry song, int maxBitrate) throws Exception { + StringBuilder builder = new StringBuilder(getRestUrl(context, "stream")); + builder.append("&id=").append(song.getId()); + + // If we are doing mp3 to mp3, just specify raw so that stuff works better + if("mp3".equals(song.getSuffix()) && (song.getTranscodedSuffix() == null || "mp3".equals(song.getTranscodedSuffix())) && ServerInfo.checkServerVersion(context, "1.9", getInstance(context))) { + builder.append("&format=raw"); + builder.append("&estimateContentLength=true"); + } else { + builder.append("&maxBitRate=").append(maxBitrate); + } + + String url = builder.toString(); + url = Util.replaceInternalUrl(context, url); + url = rewriteUrlWithRedirect(context, url); + Log.i(TAG, "Using music URL: " + stripUrlInfo(url)); + return url; + } + + @Override + public String getVideoUrl(int maxBitrate, Context context, String id) { + StringBuilder builder = new StringBuilder(getRestUrl(context, "videoPlayer")); + builder.append("&id=").append(id); + builder.append("&maxBitRate=").append(maxBitrate); + builder.append("&autoplay=true"); + + String url = rewriteUrlWithRedirect(context, builder.toString()); + Log.i(TAG, "Using video URL: " + stripUrlInfo(url)); + return url; + } + + @Override + public String getVideoStreamUrl(String format, int maxBitrate, Context context, String id) throws Exception { + StringBuilder builder = new StringBuilder(getRestUrl(context, "stream")); + builder.append("&id=").append(id); + if(!"raw".equals(format)) { + checkServerVersion(context, "1.9", "Video streaming not supported."); + builder.append("&maxBitRate=").append(maxBitrate); + } + builder.append("&format=").append(format); + + String url = rewriteUrlWithRedirect(context, builder.toString()); + Log.i(TAG, "Using video URL: " + stripUrlInfo(url)); + return url; + } + + @Override + public String getHlsUrl(String id, int bitRate, Context context) throws Exception { + checkServerVersion(context, "1.9", "HLS video streaming not supported."); + + StringBuilder builder = new StringBuilder(getRestUrl(context, "hls")); + builder.append("&id=").append(id); + if(bitRate > 0) { + builder.append("&bitRate=").append(bitRate); + } + + String url = rewriteUrlWithRedirect(context, builder.toString()); + Log.i(TAG, "Using hls URL: " + stripUrlInfo(url)); + return url; + } + + @Override + public RemoteStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception { + int n = ids.size(); + List parameterNames = new ArrayList(n + 1); + parameterNames.add("action"); + for (int i = 0; i < n; i++) { + parameterNames.add("id"); + } + List parameterValues = new ArrayList(); + parameterValues.add("set"); + parameterValues.addAll(ids); + + return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues); + } + + @Override + public RemoteStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = Arrays.asList("action", "index", "offset"); + List parameterValues = Arrays.asList("skip", index, offsetSeconds); + return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues); + } + + @Override + public RemoteStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception { + return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.asList("stop")); + } + + @Override + public RemoteStatus startJukebox(Context context, ProgressListener progressListener) throws Exception { + return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.asList("start")); + } + + @Override + public RemoteStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception { + return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.asList("status")); + } + + @Override + public RemoteStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = Arrays.asList("action", "gain"); + List parameterValues = Arrays.asList("setGain", gain); + return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues); + + } + + private RemoteStatus executeJukeboxCommand(Context context, ProgressListener progressListener, List parameterNames, List parameterValues) throws Exception { + checkServerVersion(context, "1.7", "Jukebox not supported."); + Reader reader = getReader(context, progressListener, "jukeboxControl", null, parameterNames, parameterValues); + try { + return new JukeboxStatusParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void setStarred(List entries, List artists, List albums, boolean starred, ProgressListener progressListener, Context context) throws Exception { + checkServerVersion(context, "1.8", "Starring is not supported."); + + List names = new ArrayList(); + List values = new ArrayList(); + + if(entries != null && entries.size() > 0) { + if(entries.size() > 1) { + for (MusicDirectory.Entry entry : entries) { + names.add("id"); + values.add(entry.getId()); + } + } else { + names.add("id"); + values.add(getOfflineSongId(entries.get(0).getId(), context, progressListener)); + } + } + if(artists != null && artists.size() > 0) { + for (MusicDirectory.Entry artist : artists) { + names.add("artistId"); + values.add(artist.getId()); + } + } + if(albums != null && albums.size() > 0) { + for (MusicDirectory.Entry album : albums) { + names.add("albumId"); + values.add(album.getId()); + } + } + + Reader reader = getReader(context, progressListener, starred ? "star" : "unstar", null, names, values); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public List getShares(Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.6", "Shares not supported."); + + Reader reader = getReader(context, progressListener, "getShares", null); + try { + return new ShareParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public List createShare(List ids, String description, Long expires, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = new LinkedList(); + List parameterValues = new LinkedList(); + + for (String id : ids) { + parameterNames.add("id"); + parameterValues.add(id); + } + + if (description != null) { + parameterNames.add("description"); + parameterValues.add(description); + } + + if (expires > 0) { + parameterNames.add("expires"); + parameterValues.add(expires); + } + + Reader reader = getReader(context, progressListener, "createShare", null, parameterNames, parameterValues); + try { + return new ShareParser(context, getInstance(context)).parse(reader, progressListener); + } + finally { + Util.close(reader); + } + } + + @Override + public void deleteShare(String id, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.6", "Shares not supported."); + + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("id"); + parameterValues.add(id); + + Reader reader = getReader(context, progressListener, "deleteShare", params, parameterNames, parameterValues); + + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } + finally { + Util.close(reader); + } + } + + @Override + public void updateShare(String id, String description, Long expires, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.6", "Updating share not supported."); + + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("id"); + parameterValues.add(id); + + if (description != null) { + parameterNames.add("description"); + parameterValues.add(description); + } + + parameterNames.add("expires"); + parameterValues.add(expires); + + Reader reader = getReader(context, progressListener, "updateShare", params, parameterNames, parameterValues); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } + finally { + Util.close(reader); + } + } + + @Override + public List getChatMessages(Long since, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.2", "Chat not supported."); + + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("since"); + parameterValues.add(since); + + Reader reader = getReader(context, progressListener, "getChatMessages", params, parameterNames, parameterValues); + + try { + return new ChatMessageParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void addChatMessage(String message, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.2", "Chat not supported."); + + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("message"); + parameterValues.add(message); + + Reader reader = getReader(context, progressListener, "addChatMessage", params, parameterNames, parameterValues); + + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public List getGenres(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Genres not supported."); + + Reader reader = getReader(context, progressListener, "getGenres", null); + try { + return new GenreParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getSongsByGenre(String genre, int count, int offset, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Genres not supported."); + + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("genre"); + parameterValues.add(genre); + parameterNames.add("count"); + parameterValues.add(count); + parameterNames.add("offset"); + parameterValues.add(offset); + + // Add folder if it was set and is non null + int instance = getInstance(context); + if(Util.getAlbumListsPerFolder(context, instance)) { + String folderId = Util.getSelectedMusicFolderId(context, instance); + if(folderId != null) { + parameterNames.add("musicFolderId"); + parameterValues.add(folderId); + } + } + + Reader reader = getReader(context, progressListener, "getSongsByGenre", params, parameterNames, parameterValues, true); + try { + return new RandomSongsParser(context, instance).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getTopTrackSongs(String artist, int size, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("artist"); + parameterValues.add(artist); + parameterNames.add("size"); + parameterValues.add(size); + + Reader reader = getReader(context, progressListener, "getTopTrackSongs", null, parameterNames, parameterValues); + try { + return new RandomSongsParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public List getPodcastChannels(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.6", "Podcasts not supported."); + + Reader reader = getReader(context, progressListener, "getPodcasts", null, Arrays.asList("includeEpisodes"), Arrays.asList("false")); + try { + List channels = new PodcastChannelParser(context, getInstance(context)).parse(reader, progressListener); + + String content = ""; + for(PodcastChannel channel: channels) { + content += channel.getName() + "\n"; + } + + File file = FileUtil.getPodcastFile(context, Util.getServerName(context, getInstance(context))); + BufferedWriter bw = new BufferedWriter(new FileWriter(file)); + bw.write(content); + bw.close(); + + return channels; + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getPodcastEpisodes(boolean refresh, String id, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getPodcasts", null, Arrays.asList("id"), Arrays.asList(id)); + try { + return new PodcastEntryParser(context, getInstance(context)).parse(id, reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void refreshPodcasts(Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Refresh podcasts not supported."); + + Reader reader = getReader(context, progressListener, "refreshPodcasts", null); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void createPodcastChannel(String url, Context context, ProgressListener progressListener) throws Exception{ + checkServerVersion(context, "1.9", "Creating podcasts not supported."); + + Reader reader = getReader(context, progressListener, "createPodcastChannel", null, "url", url); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void deletePodcastChannel(String id, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Deleting podcasts not supported."); + + Reader reader = getReader(context, progressListener, "deletePodcastChannel", null, "id", id); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void downloadPodcastEpisode(String id, Context context, ProgressListener progressListener) throws Exception{ + checkServerVersion(context, "1.9", "Downloading podcasts not supported."); + + Reader reader = getReader(context, progressListener, "downloadPodcastEpisode", null, "id", id); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void deletePodcastEpisode(String id, String parent, ProgressListener progressListener, Context context) throws Exception{ + checkServerVersion(context, "1.9", "Deleting podcasts not supported."); + + Reader reader = getReader(context, progressListener, "deletePodcastEpisode", null, "id", id); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void setRating(MusicDirectory.Entry entry, int rating, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.6", "Setting ratings not supported."); + + Reader reader = getReader(context, progressListener, "setRating", null, Arrays.asList("id", "rating"), Arrays.asList(entry.getId(), rating)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getBookmarks(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Bookmarks not supported."); + + Reader reader = getReader(context, progressListener, "getBookmarks", null); + try { + return new BookmarkParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void createBookmark(MusicDirectory.Entry entry, int position, String comment, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Creating bookmarks not supported."); + + Reader reader = getReader(context, progressListener, "createBookmark", null, Arrays.asList("id", "position", "comment"), Arrays.asList(entry.getId(), position, comment)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void deleteBookmark(MusicDirectory.Entry entry, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.9", "Deleting bookmarks not supported."); + + Reader reader = getReader(context, progressListener, "deleteBookmark", null, Arrays.asList("id"), Arrays.asList(entry.getId())); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public User getUser(boolean refresh, String username, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getUser", null, Arrays.asList("username"), Arrays.asList(username)); + try { + List users = new UserParser(context, getInstance(context)).parse(reader, progressListener); + if(users.size() > 0) { + // Should only have returned one anyways + return users.get(0); + } else { + return null; + } + } finally { + Util.close(reader); + } + } + + @Override + public List getUsers(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.8", "Getting user list is not supported"); + + Reader reader = getReader(context, progressListener, "getUsers", null); + try { + return new UserParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void createUser(User user, Context context, ProgressListener progressListener) throws Exception { + List names = new ArrayList(); + List values = new ArrayList(); + + names.add("username"); + values.add(user.getUsername()); + names.add("email"); + values.add(user.getEmail()); + names.add("password"); + values.add(user.getPassword()); + + for(User.Setting setting: user.getSettings()) { + names.add(setting.getName()); + values.add(setting.getValue()); + } + + Reader reader = getReader(context, progressListener, "createUser", null, names, values); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void updateUser(User user, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.10", "Updating user is not supported"); + + List names = new ArrayList(); + List values = new ArrayList(); + + names.add("username"); + values.add(user.getUsername()); + + for(User.Setting setting: user.getSettings()) { + if(setting.getName().indexOf("Role") != -1) { + names.add(setting.getName()); + values.add(setting.getValue()); + } + } + + Reader reader = getReader(context, progressListener, "updateUser", null, names, values); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void deleteUser(String username, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "deleteUser", null, Arrays.asList("username"), Arrays.asList(username)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void changeEmail(String username, String email, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "updateUser", null, Arrays.asList("username", "email"), Arrays.asList(username, email)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public void changePassword(String username, String password, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "changePassword", null, Arrays.asList("username", "password"), Arrays.asList(username, password)); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public Bitmap getAvatar(String username, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + // Return silently if server is too old + if (!ServerInfo.checkServerVersion(context, "1.8")) { + return null; + } + + // Synchronize on the username so that we don't download concurrently for + // the same user. + synchronized (username) { + // Use cached file, if existing. + Bitmap bitmap = FileUtil.getAvatarBitmap(context, username, size); + if(bitmap != null) { + return bitmap; + } + + String url = Util.getRestUrl(context, "getAvatar"); + InputStream in = null; + try + { + List parameterNames; + List parameterValues; + + parameterNames = Collections.singletonList("username"); + parameterValues = Arrays.asList(username); + + HttpEntity entity = getEntityForURL(context, url, null, parameterNames, parameterValues, progressListener, task); + in = entity.getContent(); + Header contentEncoding = entity.getContentEncoding(); + if (contentEncoding != null && contentEncoding.getValue().equalsIgnoreCase("gzip")) { + in = new GZIPInputStream(in); + } + + // If content type is XML, an error occurred. Get it. + String contentType = Util.getContentType(entity); + if (contentType != null && (contentType.startsWith("text/xml") || contentType.startsWith("text/html"))) { + new ErrorParser(context, getInstance(context)).parse(new InputStreamReader(in, Constants.UTF_8)); + return null; // Never reached. + } + + byte[] bytes = Util.toByteArray(in); + if(task != null && task.isCancelled()) { + // Handle case where partial is downloaded and cancelled + return null; + } + + OutputStream out = null; + try { + out = new FileOutputStream(FileUtil.getAvatarFile(context, username)); + out.write(bytes); + } finally { + Util.close(out); + } + + return FileUtil.getSampledBitmap(bytes, size, false); + } + finally { + Util.close(in); + } + } + } + + @Override + public ArtistInfo getArtistInfo(String id, boolean refresh, boolean allowNetwork, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.11", "Getting artist info is not supported"); + + Reader reader = getReader(context, progressListener, Util.isTagBrowsing(context, getInstance(context)) ? "getArtistInfo2" : "getArtistInfo", null, Arrays.asList("id", "includeNotPresent"), Arrays.asList(id, "true")); + try { + return new ArtistInfoParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public Bitmap getBitmap(String url, int size, Context context, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + // Synchronize on the url so that we don't download concurrently + synchronized (url) { + // Use cached file, if existing. + Bitmap bitmap = FileUtil.getMiscBitmap(context, url, size); + if(bitmap != null) { + return bitmap; + } + + InputStream in = null; + try { + HttpEntity entity = getEntityForURL(context, url, null, null, null, progressListener, task); + in = entity.getContent(); + Header contentEncoding = entity.getContentEncoding(); + if (contentEncoding != null && contentEncoding.getValue().equalsIgnoreCase("gzip")) { + in = new GZIPInputStream(in); + } + + // If content type is XML, an error occurred. Get it. + String contentType = Util.getContentType(entity); + if (contentType != null && (contentType.startsWith("text/xml") || contentType.startsWith("text/html"))) { + new ErrorParser(context, getInstance(context)).parse(new InputStreamReader(in, Constants.UTF_8)); + return null; // Never reached. + } + + byte[] bytes = Util.toByteArray(in); + if(task != null && task.isCancelled()) { + // Handle case where partial is downloaded and cancelled + return null; + } + + OutputStream out = null; + try { + out = new FileOutputStream(FileUtil.getMiscFile(context, url)); + out.write(bytes); + } finally { + Util.close(out); + } + + return FileUtil.getSampledBitmap(bytes, size, false); + } + finally { + Util.close(in); + } + } + } + + @Override + public MusicDirectory getVideos(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getVideos", null, true); + try { + return new VideosParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void savePlayQueue(List songs, MusicDirectory.Entry currentPlaying, int position, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = new LinkedList(); + List parameterValues = new LinkedList(); + + for(MusicDirectory.Entry song: songs) { + parameterNames.add("id"); + parameterValues.add(song.getId()); + } + + parameterNames.add("current"); + parameterValues.add(currentPlaying.getId()); + + parameterNames.add("position"); + parameterValues.add(position); + + Reader reader = getReader(context, progressListener, "savePlayQueue", null, parameterNames, parameterValues); + try { + new ErrorParser(context, getInstance(context)).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public PlayerQueue getPlayQueue(Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getPlayQueue", null); + try { + return new PlayQueueParser(context, getInstance(context)).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public int processOfflineSyncs(final Context context, final ProgressListener progressListener) throws Exception{ + return processOfflineScrobbles(context, progressListener) + processOfflineStars(context, progressListener); + } + + public int processOfflineScrobbles(final Context context, final ProgressListener progressListener) throws Exception { + SharedPreferences offline = Util.getOfflineSync(context); + SharedPreferences.Editor offlineEditor = offline.edit(); + int count = offline.getInt(Constants.OFFLINE_SCROBBLE_COUNT, 0); + int retry = 0; + for(int i = 1; i <= count; i++) { + String id = offline.getString(Constants.OFFLINE_SCROBBLE_ID + i, null); + long time = offline.getLong(Constants.OFFLINE_SCROBBLE_TIME + i, 0); + if(id != null) { + scrobble(id, true, time, context, progressListener); + } else { + String search = offline.getString(Constants.OFFLINE_SCROBBLE_SEARCH + i, ""); + try{ + SearchCritera critera = new SearchCritera(search, 0, 0, 1); + SearchResult result = searchNew(critera, context, progressListener); + if(result.getSongs().size() == 1){ + Log.i(TAG, "Query '" + search + "' returned song " + result.getSongs().get(0).getTitle() + " by " + result.getSongs().get(0).getArtist() + " with id " + result.getSongs().get(0).getId()); + Log.i(TAG, "Scrobbling " + result.getSongs().get(0).getId() + " with time " + time); + scrobble(result.getSongs().get(0).getId(), true, time, context, progressListener); + } + else{ + throw new Exception("Song not found on server"); + } + } + catch(Exception e){ + Log.e(TAG, e.toString()); + retry++; + } + } + } + + offlineEditor.putInt(Constants.OFFLINE_SCROBBLE_COUNT, 0); + offlineEditor.commit(); + + return count - retry; + } + + public int processOfflineStars(final Context context, final ProgressListener progressListener) throws Exception { + SharedPreferences offline = Util.getOfflineSync(context); + SharedPreferences.Editor offlineEditor = offline.edit(); + int count = offline.getInt(Constants.OFFLINE_STAR_COUNT, 0); + int retry = 0; + for(int i = 1; i <= count; i++) { + String id = offline.getString(Constants.OFFLINE_STAR_ID + i, null); + boolean starred = offline.getBoolean(Constants.OFFLINE_STAR_SETTING + i, false); + if(id != null) { + setStarred(Arrays.asList(new MusicDirectory.Entry(id)), null, null, starred, progressListener, context); + } else { + String search = offline.getString(Constants.OFFLINE_STAR_SEARCH + i, ""); + try{ + SearchCritera critera = new SearchCritera(search, 0, 1, 1); + SearchResult result = searchNew(critera, context, progressListener); + if(result.getSongs().size() == 1) { + MusicDirectory.Entry song = result.getSongs().get(0); + Log.i(TAG, "Query '" + search + "' returned song " + song.getTitle() + " by " + song.getArtist() + " with id " + song.getId()); + setStarred(Arrays.asList(song), null, null, starred, progressListener, context); + } else if(result.getAlbums().size() == 1) { + MusicDirectory.Entry album = result.getAlbums().get(0); + Log.i(TAG, "Query '" + search + "' returned album " + album.getTitle() + " by " + album.getArtist() + " with id " + album.getId()); + if(Util.isTagBrowsing(context, getInstance(context))) { + setStarred(null, null, Arrays.asList(album), starred, progressListener, context); + } else { + setStarred(Arrays.asList(album), null, null, starred, progressListener, context); + } + } + else { + throw new Exception("Song not found on server"); + } + } + catch(Exception e) { + Log.e(TAG, e.toString()); + retry++; + } + } + } + + offlineEditor.putInt(Constants.OFFLINE_STAR_COUNT, 0); + offlineEditor.commit(); + + return count - retry; + } + + private String getOfflineSongId(String id, Context context, ProgressListener progressListener) throws Exception { + SharedPreferences prefs = Util.getPreferences(context); + String cacheLocn = prefs.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, null); + if(cacheLocn != null && id.indexOf(cacheLocn) != -1) { + String searchCriteria = Util.parseOfflineIDSearch(context, id, cacheLocn); + SearchCritera critera = new SearchCritera(searchCriteria, 0, 0, 1); + SearchResult result = searchNew(critera, context, progressListener); + if(result.getSongs().size() == 1){ + id = result.getSongs().get(0).getId(); + } + } + + return id; + } + + @Override + public void setInstance(Integer instance) throws Exception { + this.instance = instance; + } + + private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams) throws Exception { + return getReader(context, progressListener, method, requestParams, false); + } + private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams, boolean throwsError) throws Exception { + return getReader(context, progressListener, method, requestParams, Collections.emptyList(), Collections.emptyList(), throwsError); + } + + private Reader getReader(Context context, ProgressListener progressListener, String method, + HttpParams requestParams, String parameterName, Object parameterValue) throws Exception { + return getReader(context, progressListener, method, requestParams, Arrays.asList(parameterName), Arrays.asList(parameterValue)); + } + + private Reader getReader(Context context, ProgressListener progressListener, String method, + HttpParams requestParams, List parameterNames, List parameterValues) throws Exception { + return getReader(context, progressListener, method, requestParams, parameterNames, parameterValues, false); + } + private Reader getReader(Context context, ProgressListener progressListener, String method, + HttpParams requestParams, List parameterNames, List parameterValues, boolean throwErrors) throws Exception { + + if (progressListener != null) { + progressListener.updateProgress(R.string.service_connecting); + } + + String url = getRestUrl(context, method); + return getReaderForURL(context, url, requestParams, parameterNames, parameterValues, progressListener, throwErrors); + } + + private Reader getReaderForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener) throws Exception { + return getReaderForURL(context, url, requestParams, parameterNames, parameterValues, progressListener, true); + } + private Reader getReaderForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener, boolean throwErrors) throws Exception { + HttpEntity entity = getEntityForURL(context, url, requestParams, parameterNames, parameterValues, progressListener, throwErrors); + if (entity == null) { + throw new RuntimeException("No entity received for URL " + url); + } + + InputStream in = entity.getContent(); + Header contentEncoding = entity.getContentEncoding(); + if (contentEncoding != null && contentEncoding.getValue().equalsIgnoreCase("gzip")) { + in = new GZIPInputStream(in); + } + return new InputStreamReader(in, Constants.UTF_8); + } + + private HttpEntity getEntityForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener, boolean throwErrors) throws Exception { + + return getEntityForURL(context, url, requestParams, parameterNames, parameterValues, progressListener, null, throwErrors); + } + + private HttpEntity getEntityForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener, SilentBackgroundTask task) throws Exception { + return getResponseForURL(context, url, requestParams, parameterNames, parameterValues, null, progressListener, task, false).getEntity(); + } + private HttpEntity getEntityForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener, SilentBackgroundTask task, boolean throwsError) throws Exception { + return getResponseForURL(context, url, requestParams, parameterNames, parameterValues, null, progressListener, task, throwsError).getEntity(); + } + + private HttpResponse getResponseForURL(Context context, String url, HttpParams requestParams, + List parameterNames, List parameterValues, + List
headers, ProgressListener progressListener, SilentBackgroundTask task, boolean throwsErrors) throws Exception { + // If not too many parameters, extract them to the URL rather than relying on the HTTP POST request being + // received intact. Remember, HTTP POST requests are converted to GET requests during HTTP redirects, thus + // loosing its entity. + if (parameterNames != null && parameterNames.size() < 10) { + StringBuilder builder = new StringBuilder(url); + for (int i = 0; i < parameterNames.size(); i++) { + builder.append("&").append(parameterNames.get(i)).append("="); + String part = URLEncoder.encode(String.valueOf(parameterValues.get(i)), "UTF-8"); + part = part.replaceAll("\\%27", "'"); + builder.append(part); + } + url = builder.toString(); + parameterNames = null; + parameterValues = null; + } + + String rewrittenUrl = rewriteUrlWithRedirect(context, url); + return executeWithRetry(context, rewrittenUrl, url, requestParams, parameterNames, parameterValues, headers, progressListener, task, throwsErrors); + } + + private HttpResponse executeWithRetry(final Context context, String url, String originalUrl, HttpParams requestParams, + List parameterNames, List parameterValues, + List
headers, ProgressListener progressListener, SilentBackgroundTask task, boolean throwErrors) throws Exception { + // Strip out sensitive information from log + if(url.indexOf("scanstatus") == -1) { + Log.i(TAG, stripUrlInfo(url)); + } + + SharedPreferences prefs = Util.getPreferences(context); + int networkTimeout = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_NETWORK_TIMEOUT, "15000")); + HttpParams newParams = httpClient.getParams(); + HttpConnectionParams.setSoTimeout(newParams, networkTimeout); + httpClient.setParams(newParams); + + final AtomicReference isCancelled = new AtomicReference(false); + int attempts = 0; + while (true) { + attempts++; + HttpContext httpContext = new BasicHttpContext(); + final HttpPost request = new HttpPost(url); + + if (task != null) { + // Attempt to abort the HTTP request if the task is cancelled. + task.setOnCancelListener(new BackgroundTask.OnCancelListener() { + @Override + public void onCancel() { + try { + isCancelled.set(true); + if(Thread.currentThread() == Looper.getMainLooper().getThread()) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + request.abort(); + return null; + } + }.execute(); + } else { + request.abort(); + } + } catch(Exception e) { + Log.e(TAG, "Failed to stop http task", e); + } + } + }); + } + + if (parameterNames != null) { + List params = new ArrayList(); + for (int i = 0; i < parameterNames.size(); i++) { + params.add(new BasicNameValuePair(parameterNames.get(i), String.valueOf(parameterValues.get(i)))); + } + request.setEntity(new UrlEncodedFormEntity(params, Constants.UTF_8)); + } + + if (requestParams != null) { + request.setParams(requestParams); + } + + if (headers != null) { + for (Header header : headers) { + request.addHeader(header); + } + } + if(url.indexOf("getCoverArt") == -1 && url.indexOf("stream") == -1 && url.indexOf("getAvatar") == -1) { + request.addHeader("Accept-Encoding", "gzip"); + } + + // Set credentials to get through apache proxies that require authentication. + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + String username = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null); + String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null); + httpClient.getCredentialsProvider().setCredentials(new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT), + new UsernamePasswordCredentials(username, password)); + + try { + HttpResponse response = httpClient.execute(request, httpContext); + detectRedirect(originalUrl, context, httpContext); + return response; + } catch (IOException x) { + request.abort(); + if (attempts >= HTTP_REQUEST_MAX_ATTEMPTS || isCancelled.get() || throwErrors) { + throw x; + } + if (progressListener != null) { + String msg = context.getResources().getString(R.string.music_service_retry, attempts, HTTP_REQUEST_MAX_ATTEMPTS - 1); + progressListener.updateProgress(msg); + } + Log.w(TAG, "Got IOException " + x + " (" + attempts + "), will retry"); + increaseTimeouts(requestParams); + Thread.sleep(2000L); + } + } + } + + private void increaseTimeouts(HttpParams requestParams) { + if (requestParams != null) { + int connectTimeout = HttpConnectionParams.getConnectionTimeout(requestParams); + if (connectTimeout != 0) { + HttpConnectionParams.setConnectionTimeout(requestParams, (int) (connectTimeout * 1.3F)); + } + int readTimeout = HttpConnectionParams.getSoTimeout(requestParams); + if (readTimeout != 0) { + HttpConnectionParams.setSoTimeout(requestParams, (int) (readTimeout * 1.5F)); + } + } + } + + private void detectRedirect(String originalUrl, Context context, HttpContext httpContext) throws Exception { + HttpUriRequest request = (HttpUriRequest) httpContext.getAttribute(ExecutionContext.HTTP_REQUEST); + HttpHost host = (HttpHost) httpContext.getAttribute(ExecutionContext.HTTP_TARGET_HOST); + + // Sometimes the request doesn't contain the "http://host" part + String redirectedUrl; + if (request.getURI().getScheme() == null) { + redirectedUrl = host.toURI() + request.getURI(); + } else { + redirectedUrl = request.getURI().toString(); + } + + if(redirectedUrl != null && "http://subsonic.org/pages/".equals(redirectedUrl)) { + throw new Exception("Invalid url, redirects to http://subsonic.org/pages/"); + } + + int fromIndex = originalUrl.indexOf("/rest/"); + int toIndex = redirectedUrl.indexOf("/rest/"); + if(fromIndex != -1 && toIndex != -1 && !Util.equals(originalUrl, redirectedUrl)) { + redirectFrom = originalUrl.substring(0, fromIndex); + redirectTo = redirectedUrl.substring(0, toIndex); + + if (redirectFrom.compareTo(redirectTo) != 0) { + Log.i(TAG, redirectFrom + " redirects to " + redirectTo); + } + redirectionLastChecked = System.currentTimeMillis(); + redirectionNetworkType = getCurrentNetworkType(context); + } + } + + private String rewriteUrlWithRedirect(Context context, String url) { + + // Only cache for a certain time. + if (System.currentTimeMillis() - redirectionLastChecked > REDIRECTION_CHECK_INTERVAL_MILLIS) { + return url; + } + + // Ignore cache if network type has changed. + if (redirectionNetworkType != getCurrentNetworkType(context)) { + return url; + } + + if (redirectFrom == null || redirectTo == null) { + return url; + } + + return url.replace(redirectFrom, redirectTo); + } + + private String stripUrlInfo(String url) { + return url.substring(0, url.indexOf("?u=") + 1) + url.substring(url.indexOf("&v=") + 1); + } + + private int getCurrentNetworkType(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + return networkInfo == null ? -1 : networkInfo.getType(); + } + + public int getInstance(Context context) { + if(instance == null) { + return Util.getActiveServer(context); + } else { + return instance; + } + } + public String getRestUrl(Context context, String method) { + return getRestUrl(context, method, true); + } + public String getRestUrl(Context context, String method, boolean allowAltAddress) { + if(instance == null) { + return Util.getRestUrl(context, method, allowAltAddress); + } else { + return Util.getRestUrl(context, method, instance, allowAltAddress); + } + } + + public HttpClient getHttpClient() { + return httpClient; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/RemoteController.java b/app/src/main/java/github/daneren2005/dsub/service/RemoteController.java new file mode 100644 index 00000000..cac28c09 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/RemoteController.java @@ -0,0 +1,116 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus +*/ + +package github.daneren2005.dsub.service; + +import android.content.Context; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.Toast; + +import java.util.Iterator; +import java.util.concurrent.LinkedBlockingQueue; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.RemoteStatus; +import github.daneren2005.serverproxy.WebProxy; + +public abstract class RemoteController { + private static final String TAG = RemoteController.class.getSimpleName(); + protected DownloadService downloadService; + protected boolean nextSupported = false; + + public abstract void create(boolean playing, int seconds); + public abstract void start(); + public abstract void stop(); + public abstract void shutdown(); + + public abstract void updatePlaylist(); + public abstract void changePosition(int seconds); + public abstract void changeTrack(int index, DownloadFile song); + // Really is abstract, just don't want to require RemoteController's support it + public void changeNextTrack(DownloadFile song) { + + }; + public boolean isNextSupported() { + return this.nextSupported; + } + public abstract void setVolume(int volume); + public abstract void updateVolume(boolean up); + public abstract double getVolume(); + public boolean isSeekable() { + return true; + } + + public abstract int getRemotePosition(); + public int getRemoteDuration() { + return 0; + } + + protected abstract class RemoteTask { + abstract RemoteStatus execute() throws Exception; + + @Override + public String toString() { + return getClass().getSimpleName(); + } + } + + protected static class TaskQueue { + private final LinkedBlockingQueue queue = new LinkedBlockingQueue(); + + void add(RemoteTask jukeboxTask) { + queue.add(jukeboxTask); + } + + RemoteTask take() throws InterruptedException { + return queue.take(); + } + + void remove(Class clazz) { + try { + Iterator iterator = queue.iterator(); + while (iterator.hasNext()) { + RemoteTask task = iterator.next(); + if (clazz.equals(task.getClass())) { + iterator.remove(); + } + } + } catch (Throwable x) { + Log.w(TAG, "Failed to clean-up task queue.", x); + } + } + + void clear() { + queue.clear(); + } + } + + protected WebProxy createWebProxy() { + MusicService musicService = MusicServiceFactory.getMusicService(downloadService); + if(musicService instanceof CachedMusicService) { + return new WebProxy(downloadService, ((CachedMusicService)musicService).getMusicService().getHttpClient()); + } else { + return new WebProxy(downloadService); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/Scrobbler.java b/app/src/main/java/github/daneren2005/dsub/service/Scrobbler.java new file mode 100644 index 00000000..1f8538c9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/Scrobbler.java @@ -0,0 +1,85 @@ +package github.daneren2005.dsub.service; + +import android.content.Context; +import android.util.Log; + +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Util; + +/** + * Scrobbles played songs to Last.fm. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class Scrobbler { + private static final String TAG = Scrobbler.class.getSimpleName(); + private static final int FOUR_MINUTES = 4 * 60 * 1000; + + private String lastSubmission; + private String lastNowPlaying; + + public void conditionalScrobble(Context context, DownloadFile song, int playerPosition, int duration) { + // More than 4 minutes + if(playerPosition > FOUR_MINUTES) { + scrobble(context, song, true); + } + // More than 50% played + else if(duration > 0 && playerPosition > (duration / 2)) { + scrobble(context, song, true); + } + } + + public void scrobble(final Context context, final DownloadFile song, final boolean submission) { + if (song == null || !Util.isScrobblingEnabled(context)) { + return; + } + + // Ignore if online with no network access + if(!Util.isOffline(context) && !Util.isNetworkConnected(context)) { + return; + } + + // Ignore podcasts + if(song.getSong() instanceof PodcastEpisode) { + return; + } + + // Ignore songs which are under 30 seconds per Last.FM guidelines + Integer duration = song.getSong().getDuration(); + if(duration != null && duration > 0 && duration < 30) { + return; + } + + final String id = song.getSong().getId(); + + // Avoid duplicate registrations. + if (submission && id.equals(lastSubmission)) { + return; + } + if (!submission && id.equals(lastNowPlaying)) { + return; + } + + if (submission) { + lastSubmission = id; + } else { + lastNowPlaying = id; + } + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() { + MusicService service = MusicServiceFactory.getMusicService(context); + try { + service.scrobble(id, submission, context, null); + Log.i(TAG, "Scrobbled '" + (submission ? "submission" : "now playing") + "' for " + song); + } catch (Exception x) { + Log.i(TAG, "Failed to scrobble'" + (submission ? "submission" : "now playing") + "' for " + song, x); + } + return null; + } + }.execute(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/ServerTooOldException.java b/app/src/main/java/github/daneren2005/dsub/service/ServerTooOldException.java new file mode 100644 index 00000000..e4a951de --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/ServerTooOldException.java @@ -0,0 +1,60 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service; + +import github.daneren2005.dsub.domain.Version; + +/** + * Thrown if the REST API version implemented by the server is too old. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class ServerTooOldException extends Exception { + + private final String text; + private final Version serverVersion; + private final Version requiredVersion; + + public ServerTooOldException(String text, Version serverVersion, Version requiredVersion) { + this.text = text; + this.serverVersion = serverVersion; + this.requiredVersion = requiredVersion; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (text != null) { + builder.append(text).append(" "); + } + builder.append("Server API version too old. "); + builder.append("Requires server version ") + .append(requiredVersion.getVersion()) + .append(", but it is version ") + .append(serverVersion.getVersion()) + .append("."); + return builder.toString(); + } + + @Override + public String getMessage() { + return this.toString(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/AbstractParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/AbstractParser.java new file mode 100644 index 00000000..bc5d2199 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/AbstractParser.java @@ -0,0 +1,150 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import java.io.Reader; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import android.util.Xml; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.domain.Version; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + */ +public abstract class AbstractParser { + + protected final Context context; + protected final int instance; + private XmlPullParser parser; + private boolean rootElementFound; + + public AbstractParser(Context context, int instance) { + this.context = context; + this.instance = instance; + } + + protected Context getContext() { + return context; + } + + protected void handleError() throws Exception { + int code = getInteger("code"); + String message; + switch (code) { + case 0: + message = context.getResources().getString(R.string.parser_server_error, get("message")); + break; + case 20: + message = context.getResources().getString(R.string.parser_upgrade_client); + break; + case 30: + message = context.getResources().getString(R.string.parser_upgrade_server); + break; + case 40: + message = context.getResources().getString(R.string.parser_not_authenticated); + break; + case 50: + message = context.getResources().getString(R.string.parser_not_authorized); + break; + default: + message = get("message"); + break; + } + throw new SubsonicRESTException(code, message); + } + + protected void updateProgress(ProgressListener progressListener, int messageId) { + if (progressListener != null) { + progressListener.updateProgress(messageId); + } + } + + protected void updateProgress(ProgressListener progressListener, String message) { + if (progressListener != null) { + progressListener.updateProgress(message); + } + } + + protected String getText() { + return parser.getText(); + } + + protected String get(String name) { + return parser.getAttributeValue(null, name); + } + + protected boolean getBoolean(String name) { + return "true".equals(get(name)); + } + + protected Integer getInteger(String name) { + String s = get(name); + return s == null ? null : Integer.valueOf(s); + } + + protected Long getLong(String name) { + String s = get(name); + return s == null ? null : Long.valueOf(s); + } + + protected Float getFloat(String name) { + String s = get(name); + return s == null ? null : Float.valueOf(s); + } + + protected void init(Reader reader) throws Exception { + parser = Xml.newPullParser(); + parser.setInput(reader); + rootElementFound = false; + } + + protected int nextParseEvent() throws Exception { + return parser.next(); + } + + protected String getElementName() { + String name = parser.getName(); + if ("subsonic-response".equals(name)) { + rootElementFound = true; + String version = get("version"); + if (version != null) { + ServerInfo server = new ServerInfo(); + server.setRestVersion(new Version(version)); + + if("madsonic".equals(get("type"))) { + server.setRestType(ServerInfo.TYPE_MADSONIC); + } + server.saveServerInfo(context, instance); + } + } + return name; + } + + protected void validate() throws Exception { + if (!rootElementFound) { + throw new Exception(context.getResources().getString(R.string.background_task_parse_error)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/AlbumListParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/AlbumListParser.java new file mode 100644 index 00000000..773c241b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/AlbumListParser.java @@ -0,0 +1,61 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class AlbumListParser extends MusicDirectoryEntryParser { + + public AlbumListParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("album".equals(name)) { + MusicDirectory.Entry entry = parseEntry(""); + entry.setDirectory(true); + dir.addChild(entry); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return dir; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/ArtistInfoParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/ArtistInfoParser.java new file mode 100644 index 00000000..5c3d2412 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/ArtistInfoParser.java @@ -0,0 +1,82 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import android.util.Log; + +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.util.ProgressListener; + +public class ArtistInfoParser extends AbstractParser { + private static final String TAG = ArtistInfo.class.getSimpleName(); + + public ArtistInfoParser(Context context, int instance) { + super(context, instance); + } + + public ArtistInfo parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + ArtistInfo info = new ArtistInfo(); + List artists = new ArrayList(); + List missingArtists = new ArrayList(); + + String tagName = null; + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + tagName = getElementName(); + if ("similarArtist".equals(tagName)) { + String id = get("id"); + if(id.equals("-1")) { + missingArtists.add(get("name")); + } else { + Artist artist = new Artist(); + artist.setId(id); + artist.setName(get("name")); + artist.setStarred(get("starred") != null); + artists.add(artist); + } + } else if ("error".equals(tagName)) { + handleError(); + } + } else if(eventType == XmlPullParser.TEXT) { + if ("biography".equals(tagName) && info.getBiography() == null) { + info.setBiography(getText()); + } else if ("musicBrainzId".equals(tagName) && info.getMusicBrainzId() == null) { + info.setMusicBrainzId(getText()); + } else if ("lastFmUrl".equals(tagName) && info.getLastFMUrl() == null) { + info.setLastFMUrl(getText()); + } else if ("largeImageUrl".equals(tagName) && info.getImageUrl() == null) { + info.setImageUrl(getText()); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + info.setSimilarArtists(artists); + info.setMissingArtists(missingArtists); + return info; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/BookmarkParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/BookmarkParser.java new file mode 100644 index 00000000..8e04749c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/BookmarkParser.java @@ -0,0 +1,100 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2013 (C) Scott Jackson +*/ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; +import java.io.Reader; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +/** + * @author Scott Jackson + */ +public class BookmarkParser extends MusicDirectoryEntryParser { + public BookmarkParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List bookmarks = new ArrayList(); + Bookmark bookmark = null; + int eventType; + + boolean isDateNormalized = ServerInfo.checkServerVersion(context, "1.11"); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH); + if(isDateNormalized) { + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + do { + eventType = nextParseEvent(); + + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + + if ("bookmark".equals(name)) { + bookmark = new Bookmark(); + + try { + bookmark.setCreated(dateFormat.parse(get("created"))); + } catch (Exception e) { + bookmark.setCreated((Date) null); + } + + try { + bookmark.setChanged(dateFormat.parse(get("changed"))); + } catch (Exception e) { + bookmark.setChanged((Date) null); + } + + bookmark.setComment(get("comment")); + bookmark.setPosition(getInteger("position")); + bookmark.setUsername(get("username")); + } else if ("entry".equals(name)) { + MusicDirectory.Entry entry = parseEntry(null); + // Work around for bookmarks showing entry with a track when podcast listings don't + if("podcast".equals(get("type"))) { + entry.setTrack(null); + } + entry.setBookmark(bookmark); + bookmarks.add(entry); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return new MusicDirectory(bookmarks); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/ChatMessageParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/ChatMessageParser.java new file mode 100644 index 00000000..36835fce --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/ChatMessageParser.java @@ -0,0 +1,65 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ChatMessage; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Joshua Bahnsen + */ +public class ChatMessageParser extends AbstractParser { + + public ChatMessageParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + List result = new ArrayList(); + int eventType; + + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("chatMessage".equals(name)) { + ChatMessage chatMessage = new ChatMessage(); + chatMessage.setUsername(get("username")); + chatMessage.setTime(getLong("time")); + chatMessage.setMessage(get("message")); + result.add(chatMessage); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return result; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/ErrorParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/ErrorParser.java new file mode 100644 index 00000000..afb05928 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/ErrorParser.java @@ -0,0 +1,49 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class ErrorParser extends AbstractParser { + + public ErrorParser(Context context, int instance) { + super(context, instance); + } + + public void parse(Reader reader) throws Exception { + + init(reader); + + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG && "error".equals(getElementName())) { + handleError(); + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/GenreParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/GenreParser.java new file mode 100644 index 00000000..ddb03544 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/GenreParser.java @@ -0,0 +1,122 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import android.text.Html; +import android.util.Log; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.util.ProgressListener; + +import org.xmlpull.v1.XmlPullParser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Joshua Bahnsen + */ +public class GenreParser extends AbstractParser { + private static final String TAG = GenreParser.class.getSimpleName(); + + public GenreParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + List result = new ArrayList(); + StringReader sr = null; + + try { + BufferedReader br = new BufferedReader(reader); + String xml = null; + String line = null; + + while ((line = br.readLine()) != null) { + if (xml == null) { + xml = line; + } else { + xml += line; + } + } + br.close(); + + // Replace double escaped ampersand (&apos;) + xml = xml.replaceAll("(?:&)(amp;|lt;|gt;|#37;|apos;)", "&$1"); + + // Replace unescaped ampersand + xml = xml.replaceAll("&(?!amp;|lt;|gt;|#37;|apos;)", "&"); + + // Replace unescaped percent symbol + // No replacements for <> at this time + xml = xml.replaceAll("%", "%"); + + xml = xml.replaceAll("'", "'"); + + sr = new StringReader(xml); + } catch (IOException ioe) { + Log.e(TAG, "Error parsing Genre XML", ioe); + } + + if (sr == null) { + Log.w(TAG, "Unable to parse Genre XML, returning empty list"); + return result; + } + + init(sr); + + Genre genre = null; + + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("genre".equals(name)) { + genre = new Genre(); + genre.setSongCount(getInteger("songCount")); + genre.setAlbumCount(getInteger("albumCount")); + } else if ("error".equals(name)) { + handleError(); + } else { + genre = null; + } + } else if (eventType == XmlPullParser.TEXT) { + if (genre != null) { + String value = getText(); + if (genre != null) { + genre.setName(Html.fromHtml(value).toString()); + genre.setIndex(value.substring(0, 1)); + result.add(genre); + genre = null; + } + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return Genre.GenreComparator.sort(result); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/IndexesParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/IndexesParser.java new file mode 100644 index 00000000..0ac86476 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/IndexesParser.java @@ -0,0 +1,134 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import java.io.Reader; +import java.util.HashMap; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import android.content.SharedPreferences; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ProgressListener; +import android.util.Log; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + */ +public class IndexesParser extends MusicDirectoryEntryParser { + private static final String TAG = IndexesParser.class.getSimpleName(); + + public IndexesParser(Context context, int instance) { + super(context, instance); + } + + public Indexes parse(Reader reader, ProgressListener progressListener) throws Exception { + long t0 = System.currentTimeMillis(); + init(reader); + + List artists = new ArrayList(); + List shortcuts = new ArrayList(); + List entries = new ArrayList(); + Long lastModified = null; + int eventType; + String index = "#"; + String ignoredArticles = null; + boolean changed = false; + Map artistList = new HashMap(); + + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("indexes".equals(name) || "artists".equals(name)) { + changed = true; + lastModified = getLong("lastModified"); + ignoredArticles = get("ignoredArticles"); + } else if ("index".equals(name)) { + index = get("name"); + + } else if ("artist".equals(name)) { + Artist artist = new Artist(); + artist.setId(get("id")); + artist.setName(get("name")); + artist.setIndex(index); + artist.setStarred(get("starred") != null); + + // Combine the id's for the two artists + if(artistList.containsKey(artist.getName())) { + Artist originalArtist = artistList.get(artist.getName()); + if(originalArtist.isStarred()) { + artist.setStarred(true); + } + originalArtist.setId(originalArtist.getId() + ";" + artist.getId()); + } else { + artistList.put(artist.getName(), artist); + artists.add(artist); + } + + if (artists.size() % 10 == 0) { + String msg = getContext().getResources().getString(R.string.parser_artist_count, artists.size()); + updateProgress(progressListener, msg); + } + } else if ("shortcut".equals(name)) { + Artist shortcut = new Artist(); + shortcut.setId(get("id")); + shortcut.setName(get("name")); + shortcut.setIndex("*"); + shortcut.setStarred(get("starred") != null); + shortcuts.add(shortcut); + } else if("child".equals(name)) { + MusicDirectory.Entry entry = parseEntry(""); + entries.add(entry); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + if(ignoredArticles != null) { + SharedPreferences.Editor prefs = Util.getPreferences(context).edit(); + prefs.putString(Constants.CACHE_KEY_IGNORE, ignoredArticles); + prefs.commit(); + } + + if (!changed) { + return null; + } + + long t1 = System.currentTimeMillis(); + Log.d(TAG, "Got " + artists.size() + " artist(s) in " + (t1 - t0) + "ms."); + + String msg = getContext().getResources().getString(R.string.parser_artist_count, artists.size()); + updateProgress(progressListener, msg); + + return new Indexes(lastModified == null ? 0L : lastModified, shortcuts, artists, entries); + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/JukeboxStatusParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/JukeboxStatusParser.java new file mode 100644 index 00000000..95529635 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/JukeboxStatusParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import java.io.Reader; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import github.daneren2005.dsub.domain.RemoteStatus; + +/** + * @author Sindre Mehus + */ +public class JukeboxStatusParser extends AbstractParser { + + public JukeboxStatusParser(Context context, int instance) { + super(context, instance); + } + + public RemoteStatus parse(Reader reader) throws Exception { + + init(reader); + + RemoteStatus jukeboxStatus = new RemoteStatus(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("jukeboxPlaylist".equals(name) || "jukeboxStatus".equals(name)) { + jukeboxStatus.setPositionSeconds(getInteger("position")); + jukeboxStatus.setCurrentIndex(getInteger("currentIndex")); + jukeboxStatus.setPlaying(getBoolean("playing")); + jukeboxStatus.setGain(getFloat("gain")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return jukeboxStatus; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/LicenseParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/LicenseParser.java new file mode 100644 index 00000000..78790062 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/LicenseParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.domain.Version; + +/** + * @author Sindre Mehus + */ +public class LicenseParser extends AbstractParser { + + public LicenseParser(Context context, int instance) { + super(context, instance); + } + + public ServerInfo parse(Reader reader) throws Exception { + + init(reader); + + ServerInfo serverInfo = new ServerInfo(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("subsonic-response".equals(name)) { + serverInfo.setRestVersion(new Version(get("version"))); + } else if ("license".equals(name)) { + serverInfo.setLicenseValid(getBoolean("valid")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return serverInfo; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/LyricsParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/LyricsParser.java new file mode 100644 index 00000000..e7ce7a4b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/LyricsParser.java @@ -0,0 +1,64 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Lyrics; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class LyricsParser extends AbstractParser { + + public LyricsParser(Context context, int instance) { + super(context, instance); + } + + public Lyrics parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + Lyrics lyrics = null; + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("lyrics".equals(name)) { + lyrics = new Lyrics(); + lyrics.setArtist(get("artist")); + lyrics.setTitle(get("title")); + } else if ("error".equals(name)) { + handleError(); + } + } else if (eventType == XmlPullParser.TEXT) { + if (lyrics != null && lyrics.getText() == null) { + lyrics.setText(getText()); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + return lyrics; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryEntryParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryEntryParser.java new file mode 100644 index 00000000..9542324e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryEntryParser.java @@ -0,0 +1,94 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; + +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.MusicDirectory; + +/** + * @author Sindre Mehus + */ +public class MusicDirectoryEntryParser extends AbstractParser { + public MusicDirectoryEntryParser(Context context, int instance) { + super(context, instance); + } + + protected MusicDirectory.Entry parseEntry(String artist) { + MusicDirectory.Entry entry = new MusicDirectory.Entry(); + entry.setId(get("id")); + entry.setParent(get("parent")); + entry.setArtistId(get("artistId")); + entry.setTitle(get("title")); + if(entry.getTitle() == null) { + entry.setTitle(get("name")); + } + entry.setDirectory(getBoolean("isDir")); + entry.setCoverArt(get("coverArt")); + entry.setArtist(get("artist")); + entry.setStarred(get("starred") != null); + entry.setYear(getInteger("year")); + entry.setGenre(get("genre")); + entry.setAlbum(get("album")); + entry.setRating(getInteger("userRating")); + + if (!entry.isDirectory()) { + entry.setAlbumId(get("albumId")); + entry.setTrack(getInteger("track")); + entry.setContentType(get("contentType")); + entry.setSuffix(get("suffix")); + entry.setTranscodedContentType(get("transcodedContentType")); + entry.setTranscodedSuffix(get("transcodedSuffix")); + entry.setSize(getLong("size")); + entry.setDuration(getInteger("duration")); + entry.setBitRate(getInteger("bitRate")); + entry.setPath(get("path")); + entry.setVideo(getBoolean("isVideo")); + entry.setDiscNumber(getInteger("discNumber")); + + Integer bookmark = getInteger("bookmarkPosition"); + if(bookmark != null) { + entry.setBookmark(new Bookmark(bookmark)); + } + + String type = get("type"); + if("podcast".equals(type)) { + entry.setType(MusicDirectory.Entry.TYPE_PODCAST); + } else if("audiobook".equals(type) || (entry.getGenre() != null && "audiobook".equals(entry.getGenre().toLowerCase()))) { + entry.setType(MusicDirectory.Entry.TYPE_AUDIO_BOOK); + } + } else if(!"".equals(artist)) { + entry.setPath(artist + "/" + entry.getTitle()); + } + return entry; + } + + protected MusicDirectory.Entry parseArtist() { + MusicDirectory.Entry entry = new MusicDirectory.Entry(); + + entry.setId(get("id")); + entry.setTitle(get("name")); + entry.setPath(entry.getTitle()); + entry.setStarred(true); + entry.setDirectory(true); + + return entry; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryParser.java new file mode 100644 index 00000000..a786bceb --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/MusicDirectoryParser.java @@ -0,0 +1,108 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import android.util.Log; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.HashMap; +import java.util.Map; + +import static github.daneren2005.dsub.domain.MusicDirectory.*; + +/** + * @author Sindre Mehus + */ +public class MusicDirectoryParser extends MusicDirectoryEntryParser { + + private static final String TAG = MusicDirectoryParser.class.getSimpleName(); + + public MusicDirectoryParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(String artist, Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + boolean isArtist = false; + boolean checkForDuplicates = Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_RENAME_DUPLICATES, true); + Map titleMap = new HashMap(); + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("child".equals(name) || "song".equals(name) || "video".equals(name)) { + Entry entry = parseEntry(artist); + entry.setGrandParent(dir.getParent()); + + // Only check for songs + if(checkForDuplicates && !entry.isDirectory()) { + // Check if duplicates + Entry duplicate = titleMap.get(entry.getTitle()); + if (duplicate != null) { + // Check if the first already has been rebased or not + if (duplicate.getTitle().equals(entry.getTitle())) { + duplicate.rebaseTitleOffPath(); + } + + // Rebase if this is the second instance of this title found + entry.rebaseTitleOffPath(); + } else { + titleMap.put(entry.getTitle(), entry); + } + } + + dir.addChild(entry); + } else if ("directory".equals(name) || "artist".equals(name) || ("album".equals(name) && !isArtist)) { + dir.setName(get("name")); + dir.setId(get("id")); + if(Util.isTagBrowsing(context, instance)) { + dir.setParent(get("artistId")); + } else { + dir.setParent(get("parent")); + } + isArtist = true; + } else if("album".equals(name)) { + Entry entry = parseEntry(artist); + entry.setDirectory(true); + dir.addChild(entry); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + // Only apply sorting on server version 4.7 and greater, where disc is supported + dir.sortChildren(context, instance); + + return dir; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/MusicFoldersParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/MusicFoldersParser.java new file mode 100644 index 00000000..a525084e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/MusicFoldersParser.java @@ -0,0 +1,65 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.util.ProgressListener; + +/** + * @author Sindre Mehus + */ +public class MusicFoldersParser extends AbstractParser { + + public MusicFoldersParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List result = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String tag = getElementName(); + if ("musicFolder".equals(tag)) { + String id = get("id"); + String name = get("name"); + result.add(new MusicFolder(id, name)); + } else if ("error".equals(tag)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return result; + } + +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/PlayQueueParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/PlayQueueParser.java new file mode 100644 index 00000000..ec161d2b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/PlayQueueParser.java @@ -0,0 +1,85 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service.parser; + +import android.content.Context; + +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.TimeZone; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerQueue; +import github.daneren2005.dsub.util.ProgressListener; + +public class PlayQueueParser extends MusicDirectoryEntryParser { + private static final String TAG = PlayQueueParser.class.getSimpleName(); + + public PlayQueueParser(Context context, int instance) { + super(context, instance); + } + + public PlayerQueue parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + PlayerQueue state = new PlayerQueue(); + String currentId = null; + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if("playQueue".equals(name)) { + currentId = get("current"); + state.currentPlayingPosition = getInteger("position"); + try { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + state.changed = dateFormat.parse(get("changed")); + } catch (ParseException e) { + state.changed = null; + } + } else if ("entry".equals(name)) { + MusicDirectory.Entry entry = parseEntry(""); + // Only add songs + if(!entry.isVideo()) { + state.songs.add(entry); + } + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + if(currentId != null) { + for (MusicDirectory.Entry entry : state.songs) { + if (entry.getId().equals(currentId)) { + state.currentPlayingIndex = state.songs.indexOf(entry); + } + } + } else { + state.currentPlayingIndex = 0; + state.currentPlayingPosition = 0; + } + + validate(); + return state; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistParser.java new file mode 100644 index 00000000..5bb07dfd --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistParser.java @@ -0,0 +1,63 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class PlaylistParser extends MusicDirectoryEntryParser { + + public PlaylistParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("entry".equals(name)) { + dir.addChild(parseEntry("")); + } else if ("error".equals(name)) { + handleError(); + } else if ("playlist".equals(name)) { + dir.setName(get("name")); + dir.setId(get("id")); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return dir; + } + +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistsParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistsParser.java new file mode 100644 index 00000000..6f01d510 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/PlaylistsParser.java @@ -0,0 +1,70 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; + +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.adapter.PlaylistAdapter; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Sindre Mehus + */ +public class PlaylistsParser extends AbstractParser { + + public PlaylistsParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List result = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String tag = getElementName(); + if ("playlist".equals(tag)) { + String id = get("id"); + String name = get("name"); + String owner = get("owner"); + String comment = get("comment"); + String songCount = get("songCount"); + String created = get("created"); + String pub = get("public"); + result.add(new Playlist(id, name, owner, comment, songCount, created, pub)); + } else if ("error".equals(tag)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return PlaylistAdapter.PlaylistComparator.sort(result); + } + +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/PodcastChannelParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/PodcastChannelParser.java new file mode 100644 index 00000000..36ed17de --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/PodcastChannelParser.java @@ -0,0 +1,66 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.util.ProgressListener; +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; +import org.xmlpull.v1.XmlPullParser; + +/** + * + * @author Scott + */ +public class PodcastChannelParser extends AbstractParser { + public PodcastChannelParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List channels = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("channel".equals(name)) { + PodcastChannel channel = new PodcastChannel(); + channel.setId(get("id")); + channel.setUrl(get("url")); + channel.setName(get("title")); + channel.setDescription(get("description")); + channel.setStatus(get("status")); + channel.setErrorMessage(get("errorMessage")); + channels.add(channel); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + return PodcastChannel.PodcastComparator.sort(channels, context); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/PodcastEntryParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/PodcastEntryParser.java new file mode 100644 index 00000000..00089363 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/PodcastEntryParser.java @@ -0,0 +1,112 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Bookmark; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ProgressListener; +import java.io.Reader; +import org.xmlpull.v1.XmlPullParser; + +/** + * + * @author Scott + */ +public class PodcastEntryParser extends AbstractParser { + private static int bogusId = -1; + + public PodcastEntryParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(String channel, Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory episodes = new MusicDirectory(); + int eventType; + boolean valid = false; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("channel".equals(name)) { + String id = get("id"); + if(id.equals(channel)) { + episodes.setId(id); + episodes.setName(get("title")); + valid = true; + } else { + valid = false; + } + } + else if ("episode".equals(name) && valid) { + PodcastEpisode episode = new PodcastEpisode(); + episode.setEpisodeId(get("id")); + episode.setId(get("streamId")); + episode.setTitle(get("title")); + episode.setParent(episodes.getId()); + episode.setArtist(episodes.getName()); + episode.setAlbum(get("description")); + episode.setDate(get("publishDate")); + if(episode.getDate() == null) { + episode.setDate(get("created")); + } + if(episode.getDate() != null && episode.getDate().indexOf("T") != -1) { + episode.setDate(episode.getDate().replace("T", " ")); + } + episode.setStatus(get("status")); + episode.setCoverArt(get("coverArt")); + episode.setSize(getLong("size")); + episode.setContentType(get("contentType")); + episode.setSuffix(get("suffix")); + episode.setDuration(getInteger("duration")); + episode.setBitRate(getInteger("bitRate")); + episode.setVideo(getBoolean("isVideo")); + episode.setPath(get("path")); + if(episode.getPath() == null) { + episode.setPath(FileUtil.getPodcastPath(context, episode)); + } else if(episode.getPath().indexOf("Podcasts/") == 0) { + episode.setPath(episode.getPath().substring("Podcasts/".length())); + } + + Integer bookmark = getInteger("bookmarkPosition"); + if(bookmark != null) { + episode.setBookmark(new Bookmark(bookmark)); + } + episode.setType(MusicDirectory.Entry.TYPE_PODCAST); + + if(episode.getId() == null) { + episode.setId(String.valueOf(bogusId)); + bogusId--; + } + episodes.addChild(episode); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + return episodes; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/RandomSongsParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/RandomSongsParser.java new file mode 100644 index 00000000..37057723 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/RandomSongsParser.java @@ -0,0 +1,60 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class RandomSongsParser extends MusicDirectoryEntryParser { + + public RandomSongsParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("song".equals(name)) { + dir.addChild(parseEntry("")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return dir; + } + +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/ScanStatusParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/ScanStatusParser.java new file mode 100644 index 00000000..ffb3ba05 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/ScanStatusParser.java @@ -0,0 +1,56 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.util.ProgressListener; + +public class ScanStatusParser extends AbstractParser { + + public ScanStatusParser(Context context, int instance) { + super(context, instance); + } + + public boolean parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + Boolean started = null; + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if("status".equals(name)) { + started = getBoolean("started"); + + String msg = context.getResources().getString(R.string.parser_scan_count, getInteger("count")); + progressListener.updateProgress(msg); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return started != null && started; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/SearchResult2Parser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/SearchResult2Parser.java new file mode 100644 index 00000000..8cc0c50d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/SearchResult2Parser.java @@ -0,0 +1,75 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.List; +import java.util.ArrayList; + +/** + * @author Sindre Mehus + */ +public class SearchResult2Parser extends MusicDirectoryEntryParser { + + public SearchResult2Parser(Context context, int instance) { + super(context, instance); + } + + public SearchResult parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List artists = new ArrayList(); + List albums = new ArrayList(); + List songs = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("artist".equals(name)) { + Artist artist = new Artist(); + artist.setId(get("id")); + artist.setName(get("name")); + artists.add(artist); + } else if ("album".equals(name)) { + MusicDirectory.Entry entry = parseEntry(""); + entry.setDirectory(true); + albums.add(entry); + } else if ("song".equals(name)) { + songs.add(parseEntry("")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return new SearchResult(artists, albums, songs); + } + +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/SearchResultParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/SearchResultParser.java new file mode 100644 index 00000000..252a7b20 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/SearchResultParser.java @@ -0,0 +1,65 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.SearchResult; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.Collections; +import java.util.List; +import java.util.ArrayList; + +/** + * @author Sindre Mehus + */ +public class SearchResultParser extends MusicDirectoryEntryParser { + + public SearchResultParser(Context context, int instance) { + super(context, instance); + } + + public SearchResult parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List songs = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("match".equals(name)) { + songs.add(parseEntry("")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return new SearchResult(Collections.emptyList(), Collections.emptyList(), songs); + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/ShareParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/ShareParser.java new file mode 100644 index 00000000..418393d1 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/ShareParser.java @@ -0,0 +1,126 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.domain.Share; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.ProgressListener; +import github.daneren2005.dsub.util.Util; + +import org.xmlpull.v1.XmlPullParser; +import java.io.Reader; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +/** + * @author Joshua Bahnsen + */ +public class ShareParser extends MusicDirectoryEntryParser { + private static final String TAG = ShareParser.class.getSimpleName(); + + public ShareParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + List dir = new ArrayList(); + Share share = null; + int eventType; + + SharedPreferences prefs = Util.getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + if(serverUrl.charAt(serverUrl.length() - 1) != '/') { + serverUrl += '/'; + } + serverUrl += "share/"; + + boolean isDateNormalized = ServerInfo.checkServerVersion(context, "1.11"); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH); + if(isDateNormalized) { + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + do { + eventType = nextParseEvent(); + + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + + if ("share".equals(name)) { + share = new Share(); + + try { + share.setCreated(dateFormat.parse(get("created"))); + } catch (Exception e) { + share.setCreated((Date) null); + } + + String url = get("url"); + if(url != null && url.indexOf(".php") == -1) { + url = url.replaceFirst(".*/([^/?]+).*", serverUrl + "$1"); + } + share.setUrl(url); + + share.setDescription(get("description")); + + try { + share.setExpires(dateFormat.parse(get("expires"))); + } catch (Exception e) { + share.setExpires((Date) null); + } + share.setId(get("id")); + + try { + share.setLastVisited(dateFormat.parse(get("lastVisited"))); + } catch (Exception e) { + share.setLastVisited((Date) null); + } + + share.setUsername(get("username")); + share.setVisitCount(getLong("visitCount")); + dir.add(share); + } else if ("entry".equals(name)) { + if(share != null) { + share.addEntry(parseEntry(null)); + } + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return dir; + } +} \ No newline at end of file diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/StarredListParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/StarredListParser.java new file mode 100644 index 00000000..59652e29 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/StarredListParser.java @@ -0,0 +1,69 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.service.parser; + +import android.content.Context; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Kurt Hardin + */ +public class StarredListParser extends MusicDirectoryEntryParser { + + public StarredListParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("album".equals(name) || "song".equals(name)) { + MusicDirectory.Entry entry = parseEntry(""); + if("album".equals(name)) { + entry.setDirectory(true); + } + dir.addChild(entry); + } else if("artist".equals(name)) { + MusicDirectory.Entry entry = parseArtist(); + entry.setDirectory(true); + entry.setArtist(null); + entry.setParent(null); + dir.addChild(entry); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return dir; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/SubsonicRESTException.java b/app/src/main/java/github/daneren2005/dsub/service/parser/SubsonicRESTException.java new file mode 100644 index 00000000..096597a1 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/SubsonicRESTException.java @@ -0,0 +1,19 @@ +package github.daneren2005.dsub.service.parser; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class SubsonicRESTException extends Exception { + + private final int code; + + public SubsonicRESTException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/UserParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/UserParser.java new file mode 100644 index 00000000..e20556c0 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/UserParser.java @@ -0,0 +1,73 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service.parser; + +import android.content.Context; + +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.util.ProgressListener; + +public class UserParser extends AbstractParser { + + public UserParser(Context context, int instance) { + super(context, instance); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + List result = new ArrayList(); + int eventType; + + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("user".equals(name)) { + User user = new User(); + + user.setUsername(get("username")); + user.setEmail(get("email")); + parseSetting(user, User.SCROBBLING); + for(String role: User.ROLES) { + parseSetting(user, role); + } + parseSetting(user, User.LASTFM); + + result.add(user); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return result; + } + + private void parseSetting(User user, String name) { + String value = get(name); + if(value != null) { + user.addSetting(name, "true".equals(value)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/parser/VideosParser.java b/app/src/main/java/github/daneren2005/dsub/service/parser/VideosParser.java new file mode 100644 index 00000000..f22c4a4a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/parser/VideosParser.java @@ -0,0 +1,53 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.service.parser; + +import android.content.Context; + +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ProgressListener; + +public class VideosParser extends MusicDirectoryEntryParser { + public VideosParser(Context context, int instance) { + super(context, instance); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("video".equals(name)) { + MusicDirectory.Entry entry = parseEntry(""); + dir.addChild(entry); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + return dir; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/ssl/SSLSocketFactory.java b/app/src/main/java/github/daneren2005/dsub/service/ssl/SSLSocketFactory.java new file mode 100644 index 00000000..3b1203c7 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/ssl/SSLSocketFactory.java @@ -0,0 +1,549 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package github.daneren2005.dsub.service.ssl; + +import android.util.Log; + +import org.apache.http.conn.ConnectTimeoutException; +import org.apache.http.conn.scheme.HostNameResolver; +import org.apache.http.conn.scheme.LayeredSocketFactory; +import org.apache.http.conn.ssl.AllowAllHostnameVerifier; +import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier; +import org.apache.http.conn.ssl.StrictHostnameVerifier; +import org.apache.http.conn.ssl.X509HostnameVerifier; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.Security; +import java.security.UnrecoverableKeyException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Layered socket factory for TLS/SSL connections. + *

+ * SSLSocketFactory can be used to validate the identity of the HTTPS server against a list of + * trusted certificates and to authenticate to the HTTPS server using a private key. + *

+ * SSLSocketFactory will enable server authentication when supplied with + * a {@link KeyStore trust-store} file containing one or several trusted certificates. The client + * secure socket will reject the connection during the SSL session handshake if the target HTTPS + * server attempts to authenticate itself with a non-trusted certificate. + *

+ * Use JDK keytool utility to import a trusted certificate and generate a trust-store file: + *

+ *     keytool -import -alias "my server cert" -file server.crt -keystore my.truststore
+ *    
+ *

+ * In special cases the standard trust verification process can be bypassed by using a custom + * {@link TrustStrategy}. This interface is primarily intended for allowing self-signed + * certificates to be accepted as trusted without having to add them to the trust-store file. + *

+ * The following parameters can be used to customize the behavior of this + * class: + *

    + *
  • {@link org.apache.http.params.CoreConnectionPNames#CONNECTION_TIMEOUT}
  • + *
  • {@link org.apache.http.params.CoreConnectionPNames#SO_TIMEOUT}
  • + *
+ *

+ * SSLSocketFactory will enable client authentication when supplied with + * a {@link KeyStore key-store} file containing a private key/public certificate + * pair. The client secure socket will use the private key to authenticate + * itself to the target HTTPS server during the SSL session handshake if + * requested to do so by the server. + * The target HTTPS server will in its turn verify the certificate presented + * by the client in order to establish client's authenticity + *

+ * Use the following sequence of actions to generate a key-store file + *

+ *
    + *
  • + *

    + * Use JDK keytool utility to generate a new key + *

    keytool -genkey -v -alias "my client key" -validity 365 -keystore my.keystore
    + * For simplicity use the same password for the key as that of the key-store + *

    + *
  • + *
  • + *

    + * Issue a certificate signing request (CSR) + *

    keytool -certreq -alias "my client key" -file mycertreq.csr -keystore my.keystore
    + *

    + *
  • + *
  • + *

    + * Send the certificate request to the trusted Certificate Authority for signature. + * One may choose to act as her own CA and sign the certificate request using a PKI + * tool, such as OpenSSL. + *

    + *
  • + *
  • + *

    + * Import the trusted CA root certificate + *

    keytool -import -alias "my trusted ca" -file caroot.crt -keystore my.keystore
    + *

    + *
  • + *
  • + *

    + * Import the PKCS#7 file containg the complete certificate chain + *

    keytool -import -alias "my client key" -file mycert.p7 -keystore my.keystore
    + *

    + *
  • + *
  • + *

    + * Verify the content the resultant keystore file + *

    keytool -list -v -keystore my.keystore
    + *

    + *
  • + *
+ * + * @since 4.0 + */ +public class SSLSocketFactory implements LayeredSocketFactory { + private static final String TAG = SSLSocketFactory.class.getSimpleName(); + public static final String TLS = "TLS"; + + public static final X509HostnameVerifier ALLOW_ALL_HOSTNAME_VERIFIER + = new AllowAllHostnameVerifier(); + + public static final X509HostnameVerifier BROWSER_COMPATIBLE_HOSTNAME_VERIFIER + = new BrowserCompatHostnameVerifier(); + + public static final X509HostnameVerifier STRICT_HOSTNAME_VERIFIER + = new StrictHostnameVerifier(); + + /** + * The default factory using the default JVM settings for secure connections. + */ + private static final SSLSocketFactory DEFAULT_FACTORY = new SSLSocketFactory(); + + /** + * Gets the default factory, which uses the default JVM settings for secure + * connections. + * + * @return the default factory + */ + public static SSLSocketFactory getSocketFactory() { + return DEFAULT_FACTORY; + } + + private final javax.net.ssl.SSLSocketFactory socketfactory; + private final HostNameResolver nameResolver; + // TODO: make final + private volatile X509HostnameVerifier hostnameVerifier; + + private static SSLContext createSSLContext( + String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final TrustStrategy trustStrategy) + throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException, KeyManagementException { + if (algorithm == null) { + algorithm = TLS; + } + KeyManagerFactory kmfactory = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + kmfactory.init(keystore, keystorePassword != null ? keystorePassword.toCharArray(): null); + KeyManager[] keymanagers = kmfactory.getKeyManagers(); + TrustManagerFactory tmfactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + tmfactory.init(keystore); + TrustManager[] trustmanagers = tmfactory.getTrustManagers(); + if (trustmanagers != null && trustStrategy != null) { + for (int i = 0; i < trustmanagers.length; i++) { + TrustManager tm = trustmanagers[i]; + if (tm instanceof X509TrustManager) { + trustmanagers[i] = new TrustManagerDecorator( + (X509TrustManager) tm, trustStrategy); + } + } + } + + SSLContext sslcontext = SSLContext.getInstance(algorithm); + sslcontext.init(keymanagers, trustmanagers, random); + return sslcontext; + } + + /** + * @deprecated Use {@link #SSLSocketFactory(String, KeyStore, String, KeyStore, SecureRandom, X509HostnameVerifier)} + */ + @Deprecated + public SSLSocketFactory( + final String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final HostNameResolver nameResolver) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(createSSLContext( + algorithm, keystore, keystorePassword, truststore, random, null), + nameResolver); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final X509HostnameVerifier hostnameVerifier) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(createSSLContext( + algorithm, keystore, keystorePassword, truststore, random, null), + hostnameVerifier); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final TrustStrategy trustStrategy, + final X509HostnameVerifier hostnameVerifier) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(createSSLContext( + algorithm, keystore, keystorePassword, truststore, random, trustStrategy), + hostnameVerifier); + } + + public SSLSocketFactory( + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, keystore, keystorePassword, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + public SSLSocketFactory( + final KeyStore keystore, + final String keystorePassword) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException{ + this(TLS, keystore, keystorePassword, null, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + public SSLSocketFactory( + final KeyStore truststore) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, null, null, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + final TrustStrategy trustStrategy, + final X509HostnameVerifier hostnameVerifier) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, null, null, null, null, trustStrategy, hostnameVerifier); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + final TrustStrategy trustStrategy) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, null, null, null, null, trustStrategy, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + public SSLSocketFactory(final SSLContext sslContext) { + this(sslContext, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + /** + * @deprecated Use {@link #SSLSocketFactory(SSLContext)} + */ + @Deprecated + public SSLSocketFactory( + final SSLContext sslContext, final HostNameResolver nameResolver) { + super(); + this.socketfactory = sslContext.getSocketFactory(); + this.hostnameVerifier = BROWSER_COMPATIBLE_HOSTNAME_VERIFIER; + this.nameResolver = nameResolver; + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + final SSLContext sslContext, final X509HostnameVerifier hostnameVerifier) { + super(); + this.socketfactory = sslContext.getSocketFactory(); + this.hostnameVerifier = hostnameVerifier; + this.nameResolver = null; + } + + private SSLSocketFactory() { + super(); + this.socketfactory = HttpsURLConnection.getDefaultSSLSocketFactory(); + this.hostnameVerifier = null; + this.nameResolver = null; + } + + /** + * @param params Optional parameters. Parameters passed to this method will have no effect. + * This method will create a unconnected instance of {@link Socket} class + * using {@link javax.net.ssl.SSLSocketFactory#createSocket()} method. + * @since 4.1 + */ + @SuppressWarnings("cast") + public Socket createSocket(final HttpParams params) throws IOException { + // the cast makes sure that the factory is working as expected + SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket(); + sslSocket.setEnabledProtocols(getProtocols(sslSocket)); + sslSocket.setEnabledCipherSuites(getCiphers(sslSocket)); + return sslSocket; + } + + @SuppressWarnings("cast") + public Socket createSocket() throws IOException { + // the cast makes sure that the factory is working as expected + SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket(); + sslSocket.setEnabledProtocols(getProtocols(sslSocket)); + sslSocket.setEnabledCipherSuites(getCiphers(sslSocket)); + return sslSocket; + } + + /** + * @since 4.1 + */ + public Socket connectSocket( + final Socket sock, + final InetSocketAddress remoteAddress, + final InetSocketAddress localAddress, + final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException { + if (remoteAddress == null) { + throw new IllegalArgumentException("Remote address may not be null"); + } + if (params == null) { + throw new IllegalArgumentException("HTTP parameters may not be null"); + } + SSLSocket sslsock = (SSLSocket) (sock != null ? sock : createSocket()); + if (localAddress != null) { +// sslsock.setReuseAddress(HttpConnectionParams.getSoReuseaddr(params)); + sslsock.bind(localAddress); + } + + setHostName(sslsock, remoteAddress.getHostName()); + int connTimeout = HttpConnectionParams.getConnectionTimeout(params); + int soTimeout = HttpConnectionParams.getSoTimeout(params); + + try { + sslsock.connect(remoteAddress, connTimeout); + } catch (SocketTimeoutException ex) { + throw new ConnectTimeoutException("Connect to " + remoteAddress.getHostName() + "/" + + remoteAddress.getAddress() + " timed out"); + } + sslsock.setSoTimeout(soTimeout); + if (this.hostnameVerifier != null) { + try { + this.hostnameVerifier.verify(remoteAddress.getHostName(), sslsock); + // verifyHostName() didn't blowup - good! + } catch (IOException iox) { + // close the socket before re-throwing the exception + try { sslsock.close(); } catch (Exception x) { /*ignore*/ } + throw iox; + } + } + return sslsock; + } + + + /** + * Checks whether a socket connection is secure. + * This factory creates TLS/SSL socket connections + * which, by default, are considered secure. + *
+ * Derived classes may override this method to perform + * runtime checks, for example based on the cypher suite. + * + * @param sock the connected socket + * + * @return true + * + * @throws IllegalArgumentException if the argument is invalid + */ + public boolean isSecure(final Socket sock) throws IllegalArgumentException { + if (sock == null) { + throw new IllegalArgumentException("Socket may not be null"); + } + // This instanceof check is in line with createSocket() above. + if (!(sock instanceof SSLSocket)) { + throw new IllegalArgumentException("Socket not created by this factory"); + } + // This check is performed last since it calls the argument object. + if (sock.isClosed()) { + throw new IllegalArgumentException("Socket is closed"); + } + return true; + } + + /** + * @since 4.1 + */ + public Socket createLayeredSocket( + final Socket socket, + final String host, + final int port, + final boolean autoClose) throws IOException, UnknownHostException { + SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket( + socket, + host, + port, + autoClose + ); + sslSocket.setEnabledProtocols(getProtocols(sslSocket)); + sslSocket.setEnabledCipherSuites(getCiphers(sslSocket)); + if (this.hostnameVerifier != null) { + this.hostnameVerifier.verify(host, sslSocket); + } + // verifyHostName() didn't blowup - good! + return sslSocket; + } + + @Deprecated + public void setHostnameVerifier(X509HostnameVerifier hostnameVerifier) { + if ( hostnameVerifier == null ) { + throw new IllegalArgumentException("Hostname verifier may not be null"); + } + this.hostnameVerifier = hostnameVerifier; + } + + public X509HostnameVerifier getHostnameVerifier() { + return this.hostnameVerifier; + } + + /** + * @deprecated Use {@link #connectSocket(Socket, InetSocketAddress, InetSocketAddress, HttpParams)} + */ + @Deprecated + public Socket connectSocket( + final Socket socket, + final String host, int port, + final InetAddress localAddress, int localPort, + final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException { + InetSocketAddress local = null; + if (localAddress != null || localPort > 0) { + // we need to bind explicitly + if (localPort < 0) { + localPort = 0; // indicates "any" + } + local = new InetSocketAddress(localAddress, localPort); + } + InetAddress remoteAddress; + if (this.nameResolver != null) { + remoteAddress = this.nameResolver.resolve(host); + } else { + remoteAddress = InetAddress.getByName(host); + } + InetSocketAddress remote = new InetSocketAddress(remoteAddress, port); + return connectSocket(socket, remote, local, params); + } + + /** + * @deprecated Use {@link #createLayeredSocket(Socket, String, int, boolean)} + */ + @Deprecated + public Socket createSocket( + final Socket socket, + final String host, int port, + boolean autoClose) throws IOException, UnknownHostException { + SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket(socket, host, port, autoClose); + sslSocket.setEnabledProtocols(getProtocols(sslSocket)); + sslSocket.setEnabledCipherSuites(getCiphers(sslSocket)); + setHostName(sslSocket, host); + return sslSocket; + } + + private void setHostName(SSLSocket sslsock, String hostname){ + try { + java.lang.reflect.Method setHostnameMethod = sslsock.getClass().getMethod("setHostname", String.class); + setHostnameMethod.invoke(sslsock, hostname); + } catch (Exception e) { + Log.w(TAG, "SNI not useable", e); + } + } + + private String[] getProtocols(SSLSocket sslSocket) { + String[] protocols = sslSocket.getEnabledProtocols(); + + // Remove SSLv3 if it is not the only option + if(protocols.length > 1) { + List protocolList = new ArrayList(Arrays.asList(protocols)); + protocolList.remove("SSLv3"); + protocols = protocolList.toArray(new String[protocolList.size()]); + } + + return protocols; + } + + private String[] getCiphers(SSLSocket sslSocket) { + String[] ciphers = sslSocket.getEnabledCipherSuites(); + + List enabledCiphers = new ArrayList(Arrays.asList(ciphers)); + // On Android 5.0 release, Jetty doesn't seem to play nice with these ciphers + enabledCiphers.remove("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"); + enabledCiphers.remove("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA"); + + ciphers = enabledCiphers.toArray(new String[enabledCiphers.size()]); + return ciphers; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustManagerDecorator.java b/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustManagerDecorator.java new file mode 100644 index 00000000..f2364368 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustManagerDecorator.java @@ -0,0 +1,65 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package github.daneren2005.dsub.service.ssl; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.X509TrustManager; + + +/** + * @since 4.1 + */ +class TrustManagerDecorator implements X509TrustManager { + + private final X509TrustManager trustManager; + private final TrustStrategy trustStrategy; + + TrustManagerDecorator(final X509TrustManager trustManager, final TrustStrategy trustStrategy) { + super(); + this.trustManager = trustManager; + this.trustStrategy = trustStrategy; + } + + public void checkClientTrusted( + final X509Certificate[] chain, final String authType) throws CertificateException { + this.trustManager.checkClientTrusted(chain, authType); + } + + public void checkServerTrusted( + final X509Certificate[] chain, final String authType) throws CertificateException { + if (!this.trustStrategy.isTrusted(chain, authType)) { + this.trustManager.checkServerTrusted(chain, authType); + } + } + + public X509Certificate[] getAcceptedIssuers() { + return this.trustManager.getAcceptedIssuers(); + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustSelfSignedStrategy.java b/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustSelfSignedStrategy.java new file mode 100644 index 00000000..637a8931 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustSelfSignedStrategy.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package github.daneren2005.dsub.service.ssl; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * A trust strategy that accepts self-signed certificates as trusted. Verification of all other + * certificates is done by the trust manager configured in the SSL context. + * + * @since 4.1 + */ +public class TrustSelfSignedStrategy implements TrustStrategy { + + public boolean isTrusted(final X509Certificate[] chain, final String authType) throws CertificateException { + return true; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustStrategy.java b/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustStrategy.java new file mode 100644 index 00000000..334a97c5 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/ssl/TrustStrategy.java @@ -0,0 +1,57 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package github.daneren2005.dsub.service.ssl; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * A strategy to establish trustworthiness of certificates without consulting the trust manager + * configured in the actual SSL context. This interface can be used to override the standard + * JSSE certificate verification process. + * + * @since 4.1 + */ +public interface TrustStrategy { + + /** + * Determines whether the certificate chain can be trusted without consulting the trust manager + * configured in the actual SSL context. This method can be used to override the standard JSSE + * certificate verification process. + *

+ * Please note that, if this method returns false, the trust manager configured + * in the actual SSL context can still clear the certificate as trusted. + * + * @param chain the peer certificate chain + * @param authType the authentication type based on the client certificate + * @return true if the certificate can be trusted without verification by + * the trust manager, false otherwise. + * @throws CertificateException thrown if the certificate is not trusted or invalid. + */ + boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException; + +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/AuthenticatorService.java b/app/src/main/java/github/daneren2005/dsub/service/sync/AuthenticatorService.java new file mode 100644 index 00000000..89fccb91 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/AuthenticatorService.java @@ -0,0 +1,90 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.NetworkErrorException; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; + +/** + * Created by Scott on 8/28/13. + */ + +public class AuthenticatorService extends Service { + private SubsonicAuthenticator authenticator; + + @Override + public void onCreate() { + authenticator = new SubsonicAuthenticator(this); + } + + @Override + public IBinder onBind(Intent intent) { + return authenticator.getIBinder(); + + } + + private class SubsonicAuthenticator extends AbstractAccountAuthenticator { + public SubsonicAuthenticator(Context context) { + super(context); + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + return null; + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public String getAuthTokenLabel(String authTokenType) { + return null; + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException { + return null; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncAdapter.java b/app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncAdapter.java new file mode 100644 index 00000000..f7a8634e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncAdapter.java @@ -0,0 +1,105 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.annotation.TargetApi; +import android.content.Context; +import android.util.Log; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Notifications; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.SyncUtil.SyncSet; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 8/28/13. + */ + +public class MostRecentSyncAdapter extends SubsonicSyncAdapter { + private static String TAG = MostRecentSyncAdapter.class.getSimpleName(); + + public MostRecentSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + @TargetApi(14) + public MostRecentSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + } + + @Override + public void onExecuteSync(Context context, int instance) { + try { + ArrayList syncedList = SyncUtil.getSyncedMostRecent(context, instance); + MusicDirectory albumList = musicService.getAlbumList("newest", 20, 0, context, null); + List updated = new ArrayList(); + boolean firstRun = false; + if(syncedList.size() == 0) { + // Get the initial set of albums on first run, don't sync any of these! + for(MusicDirectory.Entry album: albumList.getChildren()) { + syncedList.add(album.getId()); + } + firstRun = true; + } else { + for(MusicDirectory.Entry album: albumList.getChildren()) { + if(!syncedList.contains(album.getId())) { + if(!"Podcast".equals(album.getGenre())) { + try { + if(downloadRecursively(null, getMusicDirectory(album), context, false)) { + updated.add(album.getTitle()); + } + } catch(Exception e) { + Log.w(TAG, "Failed to get songs for " + album.getId() + " on " + Util.getServerName(context, instance)); + } + } + syncedList.add(album.getId()); + } + } + } + + if(updated.size() > 0) { + while(syncedList.size() > 40) { + syncedList.remove(0); + } + + FileUtil.serialize(context, syncedList, SyncUtil.getMostRecentSyncFile(context, instance)); + + // If there is a new album on the active server, chances are artists need to be refreshed + if(Util.getActiveServer(context) == instance) { + musicService.getIndexes(Util.getSelectedMusicFolderId(context), true, context, null); + } + + Notifications.showSyncNotification(context, R.string.sync_new_albums, SyncUtil.joinNames(updated)); + } else if(firstRun) { + FileUtil.serialize(context, syncedList, SyncUtil.getMostRecentSyncFile(context, instance)); + } + } catch(Exception e) { + Log.e(TAG, "Failed to get most recent list for " + Util.getServerName(context, instance)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncService.java b/app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncService.java new file mode 100644 index 00000000..49ea4a0e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/MostRecentSyncService.java @@ -0,0 +1,48 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Created by Scott on 8/28/13. + */ + +public class MostRecentSyncService extends Service { + private static MostRecentSyncAdapter mostRecentSyncAdapter; + private static final Object syncLock = new Object(); + + @Override + public void onCreate() { + synchronized (syncLock) { + if(mostRecentSyncAdapter == null) { + mostRecentSyncAdapter = new MostRecentSyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return mostRecentSyncAdapter.getSyncAdapterBinder(); + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncAdapter.java b/app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncAdapter.java new file mode 100644 index 00000000..a0996628 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncAdapter.java @@ -0,0 +1,153 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus +*/ + +package github.daneren2005.dsub.service.sync; + +import android.annotation.TargetApi; +import android.content.Context; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.parser.SubsonicRESTException; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Notifications; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.SyncUtil.SyncSet; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 8/28/13. +*/ + +public class PlaylistSyncAdapter extends SubsonicSyncAdapter { + private static String TAG = PlaylistSyncAdapter.class.getSimpleName(); + // Update playlists every day to make sure they are still valid + private static int MAX_PLAYLIST_AGE = 24; + + public PlaylistSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + @TargetApi(14) + public PlaylistSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + } + + @Override + public void onExecuteSync(Context context, int instance) { + String serverName = Util.getServerName(context, instance); + + List remainder = null; + try { + // Just update playlist listings so user doesn't have to + remainder = musicService.getPlaylists(true, context, null); + } catch(Exception e) { + Log.e(TAG, "Failed to refresh playlist list for " + serverName); + } + + ArrayList playlistList = SyncUtil.getSyncedPlaylists(context, instance); + List updated = new ArrayList(); + boolean removed = false; + for(int i = 0; i < playlistList.size(); i++) { + SyncSet cachedPlaylist = playlistList.get(i); + String id = cachedPlaylist.id; + + // Remove playlist from remainder list + if(remainder != null) { + remainder.remove(new Playlist(id, "")); + } + + try { + MusicDirectory playlist = musicService.getPlaylist(true, id, serverName, context, null); + + // Get list of original paths + List origPathList = new ArrayList(); + if(cachedPlaylist.synced != null) { + origPathList.addAll(cachedPlaylist.synced); + } else { + cachedPlaylist.synced = new ArrayList(); + } + + for(MusicDirectory.Entry entry: playlist.getChildren()) { + DownloadFile file = new DownloadFile(context, entry, true); + String path = file.getCompleteFile().getPath(); + while(!file.isSaved() && !file.isFailedMax()) { + file.downloadNow(musicService); + if(file.isSaved() && !updated.contains(playlist.getName())) { + updated.add(playlist.getName()); + } + } + + // Add to cached path set if saved + if(file.isSaved() && !cachedPlaylist.synced.contains(path)) { + cachedPlaylist.synced.add(path); + } + + origPathList.remove(path); + } + + // Check to unpin all paths which are no longer in playlist + if(origPathList.size() > 0) { + for(String path: origPathList) { + File saveFile = new File(path); + FileUtil.unpinSong(context, saveFile); + cachedPlaylist.synced.remove(path); + } + + removed = true; + } + } catch(SubsonicRESTException e) { + if(e.getCode() == 70) { + SyncUtil.removeSyncedPlaylist(context, id, instance); + Log.i(TAG, "Unsync deleted playlist " + id + " for " + serverName); + } + } catch(Exception e) { + Log.e(TAG, "Failed to get playlist " + id + " for " + serverName, e); + } + + if(updated.size() > 0 || removed) { + SyncUtil.setSyncedPlaylists(context, instance, playlistList); + } + } + + // For remaining playlists, check to make sure they have been updated recently + if(remainder != null) { + for (Playlist playlist : remainder) { + MusicDirectory dir = FileUtil.deserialize(context, Util.getCacheName(context, instance, "playlist", playlist.getId()), MusicDirectory.class, MAX_PLAYLIST_AGE); + if (dir == null) { + try { + musicService.getPlaylist(true, playlist.getId(), serverName, context, null); + } catch(Exception e) { + Log.w(TAG, "Failed to update playlist for " + playlist.getName()); + } + } + } + } + + if(updated.size() > 0) { + Notifications.showSyncNotification(context, R.string.sync_new_playlists, SyncUtil.joinNames(updated)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncService.java b/app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncService.java new file mode 100644 index 00000000..dd1f3859 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/PlaylistSyncService.java @@ -0,0 +1,48 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Created by Scott on 8/28/13. + */ + +public class PlaylistSyncService extends Service { + private static PlaylistSyncAdapter playlistSyncAdapter; + private static final Object syncLock = new Object(); + + @Override + public void onCreate() { + synchronized (syncLock) { + if(playlistSyncAdapter == null) { + playlistSyncAdapter = new PlaylistSyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return playlistSyncAdapter.getSyncAdapterBinder(); + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncAdapter.java b/app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncAdapter.java new file mode 100644 index 00000000..985a7267 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncAdapter.java @@ -0,0 +1,113 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.annotation.TargetApi; +import android.content.Context; +import android.util.Log; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.parser.SubsonicRESTException; +import github.daneren2005.dsub.util.Notifications; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.SyncUtil.SyncSet; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 8/28/13. + */ + +public class PodcastSyncAdapter extends SubsonicSyncAdapter { + private static String TAG = PodcastSyncAdapter.class.getSimpleName(); + + public PodcastSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + @TargetApi(14) + public PodcastSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + } + + @Override + public void onExecuteSync(Context context, int instance) { + ArrayList podcastList = SyncUtil.getSyncedPodcasts(context, instance); + + try { + // Only refresh if syncs exist (implies a server where supported) + if(podcastList.size() > 0) { + // Just update podcast listings so user doesn't have to + musicService.getPodcastChannels(true, context, null); + + // Refresh podcast listings before syncing + musicService.refreshPodcasts(context, null); + } + + List updated = new ArrayList(); + for(int i = 0; i < podcastList.size(); i++) { + SyncSet set = podcastList.get(i); + String id = set.id; + List existingEpisodes = set.synced; + try { + MusicDirectory podcasts = musicService.getPodcastEpisodes(true, id, context, null); + + for(MusicDirectory.Entry entry: podcasts.getChildren()) { + // Make sure podcast is valid and not already synced + if(entry.getId() != null && "completed".equals(((PodcastEpisode)entry).getStatus()) && !existingEpisodes.contains(entry.getId())) { + DownloadFile file = new DownloadFile(context, entry, false); + while(!file.isCompleteFileAvailable() && !file.isFailedMax()) { + file.downloadNow(musicService); + } + // Only add if actualy downloaded correctly + if(file.isCompleteFileAvailable()) { + existingEpisodes.add(entry.getId()); + if(!updated.contains(podcasts.getName())) { + updated.add(podcasts.getName()); + } + } + } + } + } catch(SubsonicRESTException e) { + if(e.getCode() == 70) { + SyncUtil.removeSyncedPodcast(context, id, instance); + Log.i(TAG, "Unsync deleted podcasts for " + id + " on " + Util.getServerName(context, instance)); + } + } catch (Exception e) { + Log.w(TAG, "Failed to get podcasts for " + id + " on " + Util.getServerName(context, instance)); + } + } + + // Make sure there are is at least one change before re-syncing + if(updated.size() > 0) { + FileUtil.serialize(context, podcastList, SyncUtil.getPodcastSyncFile(context, instance)); + Notifications.showSyncNotification(context, R.string.sync_new_podcasts, SyncUtil.joinNames(updated)); + } + } catch(Exception e) { + Log.w(TAG, "Failed to get podcasts for " + Util.getServerName(context, instance)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncService.java b/app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncService.java new file mode 100644 index 00000000..e4936581 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/PodcastSyncService.java @@ -0,0 +1,48 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Created by Scott on 8/28/13. + */ + +public class PodcastSyncService extends Service { + private static PodcastSyncAdapter podcastSyncAdapter; + private static final Object syncLock = new Object(); + + @Override + public void onCreate() { + synchronized (syncLock) { + if(podcastSyncAdapter == null) { + podcastSyncAdapter = new PodcastSyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return podcastSyncAdapter.getSyncAdapterBinder(); + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncAdapter.java b/app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncAdapter.java new file mode 100644 index 00000000..cf985227 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncAdapter.java @@ -0,0 +1,80 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.content.Context; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.Notifications; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 8/28/13. + */ + +public class StarredSyncAdapter extends SubsonicSyncAdapter { + private static String TAG = StarredSyncAdapter.class.getSimpleName(); + + public StarredSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + @TargetApi(14) + public StarredSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + } + + @Override + public void onExecuteSync(Context context, int instance) { + try { + ArrayList syncedList = new ArrayList(); + MusicDirectory starredList = musicService.getStarredList(context, null); + + // Pin all the starred stuff + boolean updated = downloadRecursively(syncedList, starredList, context, true); + + // Get old starred list + ArrayList oldSyncedList = SyncUtil.getSyncedStarred(context, instance); + + // Check to make sure there aren't any old starred songs that now need to be removed + oldSyncedList.removeAll(syncedList); + + for(String path: oldSyncedList) { + File saveFile = new File(path); + FileUtil.unpinSong(context, saveFile); + } + + SyncUtil.setSyncedStarred(syncedList, context, instance); + if(updated) { + Notifications.showSyncNotification(context, R.string.sync_new_starred, null); + } + } catch(Exception e) { + Log.e(TAG, "Failed to get starred list for " + Util.getServerName(context, instance)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncService.java b/app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncService.java new file mode 100644 index 00000000..9806d09b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/StarredSyncService.java @@ -0,0 +1,48 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Created by Scott on 8/28/13. + */ + +public class StarredSyncService extends Service { + private static StarredSyncAdapter starredSyncAdapter; + private static final Object syncLock = new Object(); + + @Override + public void onCreate() { + synchronized (syncLock) { + if(starredSyncAdapter == null) { + starredSyncAdapter = new StarredSyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return starredSyncAdapter.getSyncAdapterBinder(); + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/service/sync/SubsonicSyncAdapter.java b/app/src/main/java/github/daneren2005/dsub/service/sync/SubsonicSyncAdapter.java new file mode 100644 index 00000000..661f126d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/service/sync/SubsonicSyncAdapter.java @@ -0,0 +1,174 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ + +package github.daneren2005.dsub.service.sync; + +import android.accounts.Account; +import android.annotation.TargetApi; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SyncResult; +import android.os.BatteryManager; +import android.os.Bundle; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import java.util.List; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.CachedMusicService; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.RESTMusicService; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.Util; + +/** + * Created by Scott on 9/6/13. + */ + +public class SubsonicSyncAdapter extends AbstractThreadedSyncAdapter { + private static final String TAG = SubsonicSyncAdapter.class.getSimpleName(); + protected CachedMusicService musicService = new CachedMusicService(new RESTMusicService()); + protected boolean tagBrowsing; + private Context context; + + public SubsonicSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + this.context = context; + } + @TargetApi(14) + public SubsonicSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + this.context = context; + } + + @Override + public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + + // Don't try to sync if no network! + if(networkInfo == null || !networkInfo.isConnected() || Util.isOffline(context)) { + Log.w(TAG, "Not running sync, not connected to network"); + return; + } + + // Make sure battery > x% or is charging + IntentFilter intentFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + Intent batteryStatus = context.registerReceiver(null, intentFilter); + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + if(status != BatteryManager.BATTERY_STATUS_CHARGING && status != BatteryManager.BATTERY_STATUS_FULL) { + int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + + if((level / (float)scale) < 0.15) { + Log.w(TAG, "Not running sync, battery too low"); + return; + } + } + + // Check if user wants to only sync on wifi + SharedPreferences prefs = Util.getPreferences(context); + if(prefs.getBoolean(Constants.PREFERENCES_KEY_SYNC_WIFI, true)) { + if(networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + executeSync(context); + } else { + Log.w(TAG, "Not running sync, not connected to wifi"); + } + } else { + executeSync(context); + } + } + + private void executeSync(Context context) { + String className = this.getClass().getSimpleName(); + Log.i(TAG, "Running sync for " + className); + long start = System.currentTimeMillis(); + int servers = Util.getServerCount(context); + for(int i = 1; i <= servers; i++) { + try { + if(isValidServer(context, i) && Util.isSyncEnabled(context, i)) { + tagBrowsing = Util.isTagBrowsing(context, i); + musicService.setInstance(i); + onExecuteSync(context, i); + } else { + Log.i(TAG, "Skipped sync for " + i); + } + } catch(Exception e) { + Log.e(TAG, "Failed sync for " + className + "(" + i + ")", e); + } + } + + Log.i(TAG, className + " executed in " + (System.currentTimeMillis() - start) + " ms"); + } + public void onExecuteSync(Context context, int instance) { + + } + + protected boolean downloadRecursively(List paths, MusicDirectory parent, Context context, boolean save) throws Exception { + boolean downloaded = false; + for (MusicDirectory.Entry song: parent.getChildren(false, true)) { + if (!song.isVideo()) { + DownloadFile file = new DownloadFile(context, song, save); + while(!(save && file.isSaved() || !save && file.isCompleteFileAvailable()) && !file.isFailedMax()) { + file.downloadNow(musicService); + if(!file.isFailed()) { + downloaded = true; + } + } + + if(paths != null && file.isCompleteFileAvailable()) { + paths.add(file.getCompleteFile().getPath()); + } + } + } + + for (MusicDirectory.Entry dir: parent.getChildren(true, false)) { + if(downloadRecursively(paths, getMusicDirectory(dir), context, save)) { + downloaded = true; + } + } + + return downloaded; + } + protected MusicDirectory getMusicDirectory(MusicDirectory.Entry dir) throws Exception{ + String id = dir.getId(); + String name = dir.getTitle(); + + if(tagBrowsing) { + if(dir.getArtist() == null) { + return musicService.getArtist(id, name, true, context, null); + } else { + return musicService.getAlbum(id, name, true, context, null); + } + } else { + return musicService.getMusicDirectory(id, name, true, context, null); + } + } + + private boolean isValidServer(Context context, int instance) { + String url = Util.getRestUrl(context, "null", instance, false); + return !(url.contains("demo.subsonic.org") || url.contains("yourhost")); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/updates/Updater.java b/app/src/main/java/github/daneren2005/dsub/updates/Updater.java new file mode 100644 index 00000000..a2870941 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/updates/Updater.java @@ -0,0 +1,98 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.updates; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.SilentBackgroundTask; +import github.daneren2005.dsub.util.Util; +import java.util.ArrayList; +import java.util.List; + +/** + * + * @author Scott + */ +public class Updater { + protected String TAG = Updater.class.getSimpleName(); + protected int version; + protected Context context; + + public Updater(int version) { + this.version = version; + } + + public void checkUpdates(Context context) { + this.context = context; + List updaters = new ArrayList(); + updaters.add(new Updater403()); + + SharedPreferences prefs = Util.getPreferences(context); + int lastVersion = prefs.getInt(Constants.LAST_VERSION, 0); + if(lastVersion == 0) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.LAST_VERSION, version); + editor.commit(); + } + else if(version > lastVersion) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.LAST_VERSION, version); + editor.commit(); + + Log.i(TAG, "Updating from version " + lastVersion + " to " + version); + for(Updater updater: updaters) { + if(updater.shouldUpdate(lastVersion)) { + new BackgroundUpdate(context, updater).execute(); + } + } + } + } + + public String getName() { + return this.TAG; + } + + private class BackgroundUpdate extends SilentBackgroundTask { + private final Updater updater; + + public BackgroundUpdate(Context context, Updater updater) { + super(context); + this.updater = updater; + } + + @Override + protected Void doInBackground() { + try { + updater.update(context); + } catch(Exception e) { + Log.w(TAG, "Failed to run update for " + updater.getName()); + } + return null; + } + } + + public boolean shouldUpdate(int version) { + return this.version > version; + } + public void update(Context context) { + + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/updates/Updater403.java b/app/src/main/java/github/daneren2005/dsub/updates/Updater403.java new file mode 100644 index 00000000..4f2cbf43 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/updates/Updater403.java @@ -0,0 +1,58 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.updates; + +import android.content.Context; +import android.util.Log; +import github.daneren2005.dsub.updates.Updater; +import github.daneren2005.dsub.util.Constants; +import github.daneren2005.dsub.util.FileUtil; +import java.io.File; + +/** + * + * @author Scott + */ +public class Updater403 extends Updater { + public Updater403() { + super(403); + TAG = Updater403.class.getSimpleName(); + } + + @Override + public void update(Context context) { + // Rename cover.jpeg to cover.jpg + Log.i(TAG, "Running Updater403: updating cover.jpg to albumart.jpg"); + File dir = FileUtil.getMusicDirectory(context); + if(dir != null) { + moveArt(dir); + } + } + + private void moveArt(File dir) { + for(File file: dir.listFiles()) { + if(file.isDirectory()) { + moveArt(file); + } else if("cover.jpg".equals(file.getName()) || "cover.jpeg".equals(file.getName())) { + File renamed = new File(dir, Constants.ALBUM_ART_FILE); + file.renameTo(renamed); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java b/app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java new file mode 100644 index 00000000..6e9b8309 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java @@ -0,0 +1,148 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; + +public class ArtistRadioBuffer { + private static final String TAG = ArtistRadioBuffer.class.getSimpleName(); + + private ScheduledExecutorService executorService; + private Runnable runnable; + private final ArrayList buffer = new ArrayList(); + private int lastCount = -1; + private DownloadService context; + private boolean awaitingResults = false; + private int capacity; + private int refillThreshold; + + private String artistId; + + public ArtistRadioBuffer(DownloadService context) { + this.context = context; + runnable = new Runnable() { + @Override + public void run() { + refill(); + } + }; + + // Calculate out the capacity and refill threshold based on the user's random size preference + int shuffleListSize = Integer.parseInt(Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_RANDOM_SIZE, "20")); + // ex: default 20 -> 50 + capacity = shuffleListSize * 5 / 2; + capacity = Math.min(500, capacity); + + // ex: default 20 -> 40 + refillThreshold = capacity * 4 / 5; + } + + public void setArtist(String artistId) { + if(!Util.equals(this.artistId, artistId)) { + buffer.clear(); + } + + context.clear(); + this.artistId = artistId; + awaitingResults = true; + refill(); + } + public void restoreArtist(String artistId) { + this.artistId = artistId; + awaitingResults = false; + restart(); + } + + public List get(int size) { + // Make sure fetcher is running if needed + restart(); + + List result = new ArrayList(size); + synchronized (buffer) { + while (!buffer.isEmpty() && result.size() < size) { + result.add(buffer.remove(buffer.size() - 1)); + } + } + Log.i(TAG, "Taking " + result.size() + " songs from artist radio buffer. " + buffer.size() + " remaining."); + if(result.isEmpty()) { + awaitingResults = true; + } + return result; + } + + public void shutdown() { + executorService.shutdown(); + } + + private void restart() { + synchronized(buffer) { + if(buffer.size() <= refillThreshold && lastCount != 0 && (executorService == null || executorService.isShutdown())) { + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0, 10, TimeUnit.SECONDS); + } + } + } + + private void refill() { + if (buffer != null && (buffer.size() > refillThreshold || (!Util.isNetworkConnected(context) && !Util.isOffline(context)) || lastCount == 0)) { + executorService.shutdown(); + return; + } + + try { + MusicService service = MusicServiceFactory.getMusicService(context); + + // Get capacity based + int n = capacity - buffer.size(); + MusicDirectory songs = service.getRandomSongs(n, artistId, context, null); + + synchronized (buffer) { + lastCount = 0; + for(MusicDirectory.Entry entry: songs.getChildren()) { + if(!buffer.contains(entry) && entry.getRating() != 1) { + buffer.add(entry); + lastCount++; + } + } + Log.i(TAG, "Refilled artist radio buffer with " + lastCount + " songs."); + } + } catch (Exception x) { + // Give it one more try before quitting + if(lastCount != -2) { + lastCount = -2; + } else if(lastCount == -2) { + lastCount = 0; + } + Log.w(TAG, "Failed to refill artist radio buffer.", x); + } + + if(awaitingResults) { + awaitingResults = false; + context.checkDownloads(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java b/app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java new file mode 100644 index 00000000..9b39ac82 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java @@ -0,0 +1,307 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.xmlpull.v1.XmlPullParserException; + +import android.app.Activity; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.view.ErrorDialog; + +/** + * @author Sindre Mehus + */ +public abstract class BackgroundTask implements ProgressListener { + private static final String TAG = BackgroundTask.class.getSimpleName(); + + private final Context context; + protected AtomicBoolean cancelled = new AtomicBoolean(false); + protected OnCancelListener cancelListener; + protected Runnable onCompletionListener = null; + protected Task task; + + private static final int DEFAULT_CONCURRENCY = 8; + private static final Collection threads = Collections.synchronizedCollection(new ArrayList()); + protected static final BlockingQueue queue = new LinkedBlockingQueue(10); + private static Handler handler = null; + static { + try { + handler = new Handler(Looper.getMainLooper()); + } catch(Exception e) { + // Not called from main thread + } + } + + public BackgroundTask(Context context) { + this.context = context; + + if(threads.size() < DEFAULT_CONCURRENCY) { + for(int i = threads.size(); i < DEFAULT_CONCURRENCY; i++) { + Thread thread = new Thread(new TaskRunnable(), String.format("BackgroundTask_%d", i)); + threads.add(thread); + thread.start(); + } + } + if(handler == null) { + try { + handler = new Handler(Looper.getMainLooper()); + } catch(Exception e) { + // Not called from main thread + } + } + } + + public static void stopThreads() { + for(Thread thread: threads) { + thread.interrupt(); + } + threads.clear(); + queue.clear(); + } + + protected Activity getActivity() { + return (context instanceof Activity) ? ((Activity) context) : null; + } + + protected Handler getHandler() { + return handler; + } + + public abstract void execute(); + + protected abstract T doInBackground() throws Throwable; + + protected abstract void done(T result); + + protected void error(Throwable error) { + Log.w(TAG, "Got exception: " + error, error); + Activity activity = getActivity(); + if(activity != null) { + new ErrorDialog(activity, getErrorMessage(error), true); + } + } + + protected String getErrorMessage(Throwable error) { + + if (error instanceof IOException && !Util.isNetworkConnected(context)) { + return context.getResources().getString(R.string.background_task_no_network); + } + + if (error instanceof FileNotFoundException) { + return context.getResources().getString(R.string.background_task_not_found); + } + + if (error instanceof IOException) { + return context.getResources().getString(R.string.background_task_network_error); + } + + if (error instanceof XmlPullParserException) { + return context.getResources().getString(R.string.background_task_parse_error); + } + + String message = error.getMessage(); + if (message != null) { + return message; + } + return error.getClass().getSimpleName(); + } + + public void cancel() { + if(cancelled.compareAndSet(false, true)) { + if(isRunning()) { + if(cancelListener != null) { + cancelListener.onCancel(); + } else { + task.cancel(); + } + } + + task = null; + } + } + public boolean isCancelled() { + return cancelled.get(); + } + public void setOnCancelListener(OnCancelListener listener) { + cancelListener = listener; + } + + public boolean isRunning() { + if(task == null) { + return false; + } else { + return task.isRunning(); + } + } + + @Override + public abstract void updateProgress(final String message); + + @Override + public void updateProgress(int messageId) { + updateProgress(context.getResources().getString(messageId)); + } + + public void setOnCompletionListener(Runnable onCompletionListener) { + this.onCompletionListener = onCompletionListener; + } + + protected class Task { + private Thread thread; + private AtomicBoolean taskStart = new AtomicBoolean(false); + + private void execute() throws Exception { + // Don't run if cancelled already + if(isCancelled()) { + return; + } + + try { + thread = Thread.currentThread(); + taskStart.set(true); + + final T result = doInBackground(); + if(isCancelled()) { + taskStart.set(false); + return; + } + + if(handler != null) { + handler.post(new Runnable() { + @Override + public void run() { + if(!isCancelled()) { + onDone(result); + } + + taskStart.set(false); + } + }); + } else { + taskStart.set(false); + } + } catch(InterruptedException interrupt) { + if(taskStart.get()) { + // Don't exit root thread if task cancelled + throw interrupt; + } + } catch(final Throwable t) { + if(isCancelled()) { + taskStart.set(false); + return; + } + + if(handler != null) { + handler.post(new Runnable() { + @Override + public void run() { + if(!isCancelled()) { + try { + onError(t); + } catch(Exception e) { + // Don't care + } + } + + taskStart.set(false); + } + }); + } else { + taskStart.set(false); + } + } finally { + thread = null; + } + } + + public void cancel() { + if(taskStart.compareAndSet(true, false)) { + if (thread != null) { + thread.interrupt(); + } + } + } + public boolean isCancelled() { + if(Thread.interrupted()) { + return true; + } else if(BackgroundTask.this.isCancelled()) { + return true; + } else { + return false; + } + } + public void onDone(T result) { + done(result); + + if(onCompletionListener != null) { + onCompletionListener.run(); + } + } + public void onError(Throwable t) { + error(t); + } + + public boolean isRunning() { + return taskStart.get(); + } + } + + private class TaskRunnable implements Runnable { + private boolean running = true; + + public TaskRunnable() { + + } + + @Override + public void run() { + Looper.prepare(); + while(running) { + try { + Task task = queue.take(); + task.execute(); + } catch(InterruptedException stop) { + Log.e(TAG, "Thread died"); + running = false; + threads.remove(Thread.currentThread()); + } catch(Throwable t) { + Log.e(TAG, "Unexpected crash in BackgroundTask thread", t); + } + } + } + } + + public static interface OnCancelListener { + void onCancel(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java b/app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java new file mode 100644 index 00000000..ac8fa72a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java @@ -0,0 +1,292 @@ +package github.daneren2005.dsub.util; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import android.content.Context; +import android.util.Log; +import android.os.StatFs; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MediaStoreService; + +import java.util.*; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class CacheCleaner { + + private static final String TAG = CacheCleaner.class.getSimpleName(); + private static final long MIN_FREE_SPACE = 500 * 1024L * 1024L; + private static final long MAX_COVER_ART_SPACE = 100 * 1024L * 1024L; + + private final Context context; + private final DownloadService downloadService; + private final MediaStoreService mediaStore; + + public CacheCleaner(Context context, DownloadService downloadService) { + this.context = context; + this.downloadService = downloadService; + this.mediaStore = new MediaStoreService(context); + } + + public void clean() { + new BackgroundCleanup(context).execute(); + } + public void cleanSpace() { + new BackgroundSpaceCleanup(context).execute(); + } + public void cleanPlaylists(List playlists) { + new BackgroundPlaylistsCleanup(context, playlists).execute(); + } + + private void deleteEmptyDirs(List dirs, Set undeletable) { + for (File dir : dirs) { + if (undeletable.contains(dir)) { + continue; + } + + FileUtil.deleteEmptyDir(dir); + } + } + + private long getMinimumDelete(List files, List pinned) { + if(files.size() == 0) { + return 0L; + } + + long cacheSizeBytes = Util.getCacheSizeMB(context) * 1024L * 1024L; + + long bytesUsedBySubsonic = 0L; + for (File file : files) { + bytesUsedBySubsonic += file.length(); + } + for (File file : pinned) { + bytesUsedBySubsonic += file.length(); + } + + // Ensure that file system is not more than 95% full. + StatFs stat = new StatFs(files.get(0).getPath()); + long bytesTotalFs = (long) stat.getBlockCount() * (long) stat.getBlockSize(); + long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); + long bytesUsedFs = bytesTotalFs - bytesAvailableFs; + long minFsAvailability = bytesTotalFs - MIN_FREE_SPACE; + + long bytesToDeleteCacheLimit = Math.max(bytesUsedBySubsonic - cacheSizeBytes, 0L); + long bytesToDeleteFsLimit = Math.max(bytesUsedFs - minFsAvailability, 0L); + long bytesToDelete = Math.max(bytesToDeleteCacheLimit, bytesToDeleteFsLimit); + + Log.i(TAG, "File system : " + Util.formatBytes(bytesAvailableFs) + " of " + Util.formatBytes(bytesTotalFs) + " available"); + Log.i(TAG, "Cache limit : " + Util.formatBytes(cacheSizeBytes)); + Log.i(TAG, "Cache size before : " + Util.formatBytes(bytesUsedBySubsonic)); + Log.i(TAG, "Minimum to delete : " + Util.formatBytes(bytesToDelete)); + + return bytesToDelete; + } + + private void deleteFiles(List files, Set undeletable, long bytesToDelete, boolean deletePartials) { + if (files.isEmpty()) { + return; + } + + long bytesDeleted = 0L; + for (File file : files) { + if(!deletePartials && bytesDeleted > bytesToDelete) break; + + if (bytesToDelete > bytesDeleted || (deletePartials && (file.getName().endsWith(".partial") || file.getName().contains(".partial.")))) { + if (!undeletable.contains(file) && !file.getName().equals(Constants.ALBUM_ART_FILE)) { + long size = file.length(); + if (Util.delete(file)) { + bytesDeleted += size; + mediaStore.deleteFromMediaStore(file); + } + } + } + } + + Log.i(TAG, "Deleted : " + Util.formatBytes(bytesDeleted)); + } + + private void findCandidatesForDeletion(File file, List files, List pinned, List dirs) { + if (file.isFile()) { + String name = file.getName(); + boolean isCacheFile = name.endsWith(".partial") || name.contains(".partial.") || name.endsWith(".complete") || name.contains(".complete."); + if (isCacheFile) { + files.add(file); + } else { + pinned.add(file); + } + } else { + // Depth-first + for (File child : FileUtil.listFiles(file)) { + findCandidatesForDeletion(child, files, pinned, dirs); + } + dirs.add(file); + } + } + + private void sortByAscendingModificationTime(List files) { + Collections.sort(files, new Comparator() { + @Override + public int compare(File a, File b) { + if (a.lastModified() < b.lastModified()) { + return -1; + } + if (a.lastModified() > b.lastModified()) { + return 1; + } + return 0; + } + }); + } + + private Set findUndeletableFiles() { + Set undeletable = new HashSet(5); + + for (DownloadFile downloadFile : downloadService.getDownloads()) { + undeletable.add(downloadFile.getPartialFile()); + undeletable.add(downloadFile.getCompleteFile()); + } + + undeletable.add(FileUtil.getMusicDirectory(context)); + return undeletable; + } + + private void cleanupCoverArt(Context context) { + File dir = FileUtil.getAlbumArtDirectory(context); + + List files = new ArrayList(); + long bytesUsed = 0L; + for(File file: dir.listFiles()) { + if(file.isFile()) { + files.add(file); + bytesUsed += file.length(); + } + } + + // Don't waste time sorting if under limit already + if(bytesUsed < MAX_COVER_ART_SPACE) { + return; + } + + sortByAscendingModificationTime(files); + long bytesDeleted = 0L; + for(File file: files) { + // End as soon as the space used is below the threshold + if(bytesUsed < MAX_COVER_ART_SPACE) { + break; + } + + long bytes = file.length(); + if(file.delete()) { + bytesUsed -= bytes; + bytesDeleted += bytes; + } + } + + Log.i(TAG, "Deleted " + Util.formatBytes(bytesDeleted) + " worth of cover art"); + } + + private class BackgroundCleanup extends SilentBackgroundTask { + public BackgroundCleanup(Context context) { + super(context); + } + + @Override + protected Void doInBackground() { + if (downloadService == null) { + Log.e(TAG, "DownloadService not set. Aborting cache cleaning."); + return null; + } + + try { + List files = new ArrayList(); + List pinned = new ArrayList(); + List dirs = new ArrayList(); + + findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, pinned, dirs); + sortByAscendingModificationTime(files); + + Set undeletable = findUndeletableFiles(); + + deleteFiles(files, undeletable, getMinimumDelete(files, pinned), true); + deleteEmptyDirs(dirs, undeletable); + + // Make sure cover art directory does not grow too large + cleanupCoverArt(context); + } catch (RuntimeException x) { + Log.e(TAG, "Error in cache cleaning.", x); + } + + return null; + } + } + + private class BackgroundSpaceCleanup extends SilentBackgroundTask { + public BackgroundSpaceCleanup(Context context) { + super(context); + } + + @Override + protected Void doInBackground() { + if (downloadService == null) { + Log.e(TAG, "DownloadService not set. Aborting cache cleaning."); + return null; + } + + try { + List files = new ArrayList(); + List pinned = new ArrayList(); + List dirs = new ArrayList(); + findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, pinned, dirs); + + long bytesToDelete = getMinimumDelete(files, pinned); + if(bytesToDelete > 0L) { + sortByAscendingModificationTime(files); + Set undeletable = findUndeletableFiles(); + deleteFiles(files, undeletable, bytesToDelete, false); + } + } catch (RuntimeException x) { + Log.e(TAG, "Error in cache cleaning.", x); + } + + return null; + } + } + + private class BackgroundPlaylistsCleanup extends SilentBackgroundTask { + private final List playlists; + + public BackgroundPlaylistsCleanup(Context context, List playlists) { + super(context); + this.playlists = playlists; + } + + @Override + protected Void doInBackground() { + try { + String server = Util.getServerName(context); + SortedSet playlistFiles = FileUtil.listFiles(FileUtil.getPlaylistDirectory(context, server)); + for (Playlist playlist : playlists) { + playlistFiles.remove(FileUtil.getPlaylistFile(context, server, playlist.getName())); + } + + for(File playlist : playlistFiles) { + playlist.delete(); + } + } catch (RuntimeException x) { + Log.e(TAG, "Error in playlist cache cleaning.", x); + } + + return null; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Constants.java b/app/src/main/java/github/daneren2005/dsub/util/Constants.java new file mode 100644 index 00000000..31c5bef2 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Constants.java @@ -0,0 +1,206 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public final class Constants { + + // Character encoding used throughout. + public static final String UTF_8 = "UTF-8"; + + // REST protocol version and client ID. + // Note: Keep it as low as possible to maintain compatibility with older servers. + public static final String REST_PROTOCOL_VERSION = "1.2.0"; + public static final String REST_CLIENT_ID = "DSub"; + public static final String CHROMECAST_CLIENT_ID = "DSubCC"; + public static final String LAST_VERSION = "subsonic.version"; + + // Names for intent extras. + public static final String INTENT_EXTRA_NAME_ID = "subsonic.id"; + public static final String INTENT_EXTRA_NAME_NAME = "subsonic.name"; + public static final String INTENT_EXTRA_NAME_DIRECTORY = "subsonic.directory"; + public static final String INTENT_EXTRA_NAME_CHILD_ID = "subsonic.child.id"; + public static final String INTENT_EXTRA_NAME_ARTIST = "subsonic.artist"; + public static final String INTENT_EXTRA_NAME_TITLE = "subsonic.title"; + public static final String INTENT_EXTRA_NAME_AUTOPLAY = "subsonic.playall"; + public static final String INTENT_EXTRA_NAME_QUERY = "subsonic.query"; + public static final String INTENT_EXTRA_NAME_PLAYLIST_ID = "subsonic.playlist.id"; + public static final String INTENT_EXTRA_NAME_PLAYLIST_NAME = "subsonic.playlist.name"; + public static final String INTENT_EXTRA_NAME_PLAYLIST_OWNER = "subsonic.playlist.isOwner"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_TYPE = "subsonic.albumlisttype"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_EXTRA = "subsonic.albumlistextra"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_SIZE = "subsonic.albumlistsize"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET = "subsonic.albumlistoffset"; + public static final String INTENT_EXTRA_NAME_SHUFFLE = "subsonic.shuffle"; + public static final String INTENT_EXTRA_REQUEST_SEARCH = "subsonic.requestsearch"; + public static final String INTENT_EXTRA_NAME_EXIT = "subsonic.exit" ; + public static final String INTENT_EXTRA_NAME_DOWNLOAD = "subsonic.download"; + public static final String INTENT_EXTRA_NAME_DOWNLOAD_VIEW = "subsonic.download_view"; + public static final String INTENT_EXTRA_VIEW_ALBUM = "subsonic.view_album"; + public static final String INTENT_EXTRA_NAME_PODCAST_ID = "subsonic.podcast.id"; + public static final String INTENT_EXTRA_NAME_PODCAST_NAME = "subsonic.podcast.name"; + public static final String INTENT_EXTRA_NAME_PODCAST_DESCRIPTION = "subsonic.podcast.description"; + public static final String INTENT_EXTRA_NAME_SHARE = "subsonic.share"; + public static final String INTENT_EXTRA_FRAGMENT_TYPE = "fragmentType"; + public static final String INTENT_EXTRA_REFRESH_LISTINGS = "refreshListings"; + public static final String INTENT_EXTRA_SEARCH_SONG = "searchSong"; + public static final String INTENT_EXTRA_TOP_TRACKS = "topTracks"; + public static final String INTENT_EXTRA_SHOW_ALL = "showAll"; + + // Preferences keys. + public static final String PREFERENCES_KEY_SERVER_KEY = "server"; + public static final String PREFERENCES_KEY_SERVER_COUNT = "serverCount"; + public static final String PREFERENCES_KEY_SERVER_ADD = "serverAdd"; + public static final String PREFERENCES_KEY_SERVER_REMOVE = "serverRemove"; + public static final String PREFERENCES_KEY_SERVER_INSTANCE = "serverInstanceId"; + public static final String PREFERENCES_KEY_SERVER_NAME = "serverName"; + public static final String PREFERENCES_KEY_SERVER_URL = "serverUrl"; + public static final String PREFERENCES_KEY_SERVER_INTERNAL_URL = "serverInternalUrl"; + public static final String PREFERENCES_KEY_SERVER_LOCAL_NETWORK_SSID = "serverLocalNetworkSSID"; + public static final String PREFERENCES_KEY_TEST_CONNECTION = "serverTestConnection"; + public static final String PREFERENCES_KEY_OPEN_BROWSER = "openBrowser"; + public static final String PREFERENCES_KEY_MUSIC_FOLDER_ID = "musicFolderId"; + public static final String PREFERENCES_KEY_USERNAME = "username"; + public static final String PREFERENCES_KEY_PASSWORD = "password"; + public static final String PREFERENCES_KEY_INSTALL_TIME = "installTime"; + public static final String PREFERENCES_KEY_THEME = "theme"; + public static final String PREFERENCES_KEY_FULL_SCREEN = "fullScreen"; + public static final String PREFERENCES_KEY_DISPLAY_TRACK = "displayTrack"; + public static final String PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi"; + public static final String PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile"; + public static final String PREFERENCES_KEY_MAX_VIDEO_BITRATE_WIFI = "maxVideoBitrateWifi"; + public static final String PREFERENCES_KEY_MAX_VIDEO_BITRATE_MOBILE = "maxVideoBitrateMobile"; + public static final String PREFERENCES_KEY_NETWORK_TIMEOUT = "networkTimeout"; + public static final String PREFERENCES_KEY_CACHE_SIZE = "cacheSize"; + public static final String PREFERENCES_KEY_CACHE_LOCATION = "cacheLocation"; + public static final String PREFERENCES_KEY_PRELOAD_COUNT_WIFI = "preloadCountWifi"; + public static final String PREFERENCES_KEY_PRELOAD_COUNT_MOBILE = "preloadCountMobile"; + public static final String PREFERENCES_KEY_HIDE_MEDIA = "hideMedia"; + public static final String PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons"; + public static final String PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD = "screenLitOnDownload"; + public static final String PREFERENCES_KEY_SCROBBLE = "scrobble"; + public static final String PREFERENCES_KEY_REPEAT_MODE = "repeatMode"; + public static final String PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload"; + public static final String PREFERENCES_KEY_RANDOM_SIZE = "randomSize"; + public static final String PREFERENCES_KEY_SLEEP_TIMER_DURATION = "sleepTimerDuration"; + public static final String PREFERENCES_KEY_OFFLINE = "offline"; + public static final String PREFERENCES_KEY_TEMP_LOSS = "tempLoss"; + public static final String PREFERENCES_KEY_SHUFFLE_START_YEAR = "startYear"; + public static final String PREFERENCES_KEY_SHUFFLE_END_YEAR = "endYear"; + public static final String PREFERENCES_KEY_SHUFFLE_GENRE = "genre"; + public static final String PREFERENCES_KEY_KEEP_SCREEN_ON = "keepScreenOn"; + public static final String PREFERENCES_EQUALIZER_ON = "equalizerOn"; + public static final String PREFERENCES_EQUALIZER_SETTINGS = "equalizerSettings"; + public static final String PREFERENCES_KEY_PERSISTENT_NOTIFICATION = "persistentNotification"; + public static final String PREFERENCES_KEY_GAPLESS_PLAYBACK = "gaplessPlayback"; + public static final String PREFERENCES_KEY_REMOVE_PLAYED = "removePlayed"; + public static final String PREFERENCES_KEY_SHUFFLE_MODE = "shuffleMode2"; + public static final String PREFERENCES_KEY_SHUFFLE_MODE_EXTRA = "shuffleModeExtra"; + public static final String PREFERENCES_KEY_CHAT_REFRESH = "chatRefreshRate"; + public static final String PREFERENCES_KEY_CHAT_ENABLED = "chatEnabled"; + public static final String PREFERENCES_KEY_VIDEO_PLAYER = "videoPlayer"; + public static final String PREFERENCES_KEY_CONTROL_MODE = "remoteControlMode"; + public static final String PREFERENCES_KEY_CONTROL_ID = "remoteControlId"; + public static final String PREFERENCES_KEY_SYNC_ENABLED = "syncEnabled"; + public static final String PREFERENCES_KEY_SYNC_INTERVAL = "syncInterval"; + public static final String PREFERENCES_KEY_SYNC_WIFI = "syncWifi"; + public static final String PREFERENCES_KEY_SYNC_NOTIFICATION = "syncNotification"; + public static final String PREFERENCES_KEY_SYNC_STARRED = "syncStarred"; + public static final String PREFERENCES_KEY_SYNC_MOST_RECENT = "syncMostRecent"; + public static final String PREFERENCES_KEY_PAUSE_DISCONNECT = "pauseOnDisconnect"; + public static final String PREFERENCES_KEY_HIDE_WIDGET = "hideWidget"; + public static final String PREFERENCES_KEY_PODCASTS_ENABLED = "podcastsEnabled"; + public static final String PREFERENCES_KEY_BOOKMARKS_ENABLED = "bookmarksEnabled"; + public static final String PREFERENCES_KEY_CUSTOM_SORT_ENABLED = "customSortEnabled"; + public static final String PREFERENCES_KEY_MENU_PLAY_NEXT = "showPlayNext"; + public static final String PREFERENCES_KEY_MENU_PLAY_LAST = "showPlayLast"; + public static final String PREFERENCES_KEY_MENU_STAR = "showStar"; + public static final String PREFERENCES_KEY_MENU_SHARED = "showShared"; + public static final String PREFERENCES_KEY_SHARED_ENABLED = "sharedEnabled"; + public static final String PREFERENCES_KEY_BROWSE_TAGS = "browseTags"; + public static final String PREFERENCES_KEY_OPEN_TO_TAB = "openToTab"; + public static final String PREFERENCES_KEY_OVERRIDE_SYSTEM_LANGUAGE = "overrideSystemLanguage"; + public static final String PREFERENCES_KEY_PLAY_NOW_AFTER = "playNowAfter"; + public static final String PREFERENCES_KEY_LARGE_ALBUM_ART = "largeAlbumArt"; + public static final String PREFERENCES_KEY_ADMIN_ENABLED = "adminEnabled"; + public static final String PREFERENCES_KEY_PLAYLIST_NAME = "suggestedPlaylistName"; + public static final String PREFERENCES_KEY_PLAYLIST_ID = "suggestedPlaylistId"; + public static final String PREFERENCES_KEY_SERVER_SYNC = "serverSync"; + public static final String PREFERENCES_KEY_RECENT_COUNT = "mostRecentCount"; + public static final String PREFERENCES_KEY_MENU_RATING = "showRating"; + public static final String PREFERENCES_KEY_REPLAY_GAIN = "replayGain"; + public static final String PREFERENCES_KEY_REPLAY_GAIN_BUMP = "replayGainBump2"; + public static final String PREFERENCES_KEY_REPLAY_GAIN_UNTAGGED = "replayGainUntagged2"; + public static final String PREFERENCES_KEY_REPLAY_GAIN_TYPE= "replayGainType"; + public static final String PREFERENCES_KEY_ALBUMS_PER_FOLDER = "albumsPerFolder"; + public static final String PREFERENCES_KEY_CAST_PROXY = "castProxy"; + public static final String PREFERENCES_KEY_DISABLE_EXIT_PROMPT = "disableExitPrompt"; + public static final String PREFERENCES_KEY_RENAME_DUPLICATES = "renameDuplicates"; + public static final String PREFERENCES_KEY_FIRST_LEVEL_ARTIST = "firstLevelArtist"; + public static final String PREFERENCES_KEY_START_ON_HEADPHONES = "startOnHeadphones"; + + public static final String OFFLINE_SCROBBLE_COUNT = "scrobbleCount"; + public static final String OFFLINE_SCROBBLE_ID = "scrobbleID"; + public static final String OFFLINE_SCROBBLE_SEARCH = "scrobbleTitle"; + public static final String OFFLINE_SCROBBLE_TIME = "scrobbleTime"; + public static final String OFFLINE_STAR_COUNT = "starCount"; + public static final String OFFLINE_STAR_ID = "starID"; + public static final String OFFLINE_STAR_SEARCH = "starTitle"; + public static final String OFFLINE_STAR_SETTING = "starSetting"; + + public static final String CACHE_KEY_IGNORE = "ignoreArticles"; + + public static final String MAIN_BACK_STACK = "backStackIds"; + public static final String MAIN_BACK_STACK_SIZE = "backStackIdsSize"; + public static final String FRAGMENT_LIST = "fragmentList"; + public static final String FRAGMENT_LIST2 = "fragmentList2"; + public static final String FRAGMENT_EXTRA = "fragmentExtra"; + public static final String FRAGMENT_DOWNLOAD_FLIPPER = "fragmentDownloadFlipper"; + public static final String FRAGMENT_NAME = "fragmentName"; + public static final String FRAGMENT_POSITION = "fragmentPosition"; + + // Name of the preferences file. + public static final String PREFERENCES_FILE_NAME = "github.daneren2005.dsub_preferences"; + public static final String OFFLINE_SYNC_NAME = "github.daneren2005.dsub.offline"; + public static final String OFFLINE_SYNC_DEFAULT = "syncDefaults"; + + // Account prefs + public static final String SYNC_ACCOUNT_NAME = "Subsonic Account"; + public static final String SYNC_ACCOUNT_TYPE = "subsonic.org"; + public static final String SYNC_ACCOUNT_PLAYLIST_AUTHORITY = "github.daneren2005.dsub.playlists.provider"; + public static final String SYNC_ACCOUNT_PODCAST_AUTHORITY = "github.daneren2005.dsub.podcasts.provider"; + public static final String SYNC_ACCOUNT_STARRED_AUTHORITY = "github.daneren2005.dsub.starred.provider"; + public static final String SYNC_ACCOUNT_MOST_RECENT_AUTHORITY = "github.daneren2005.dsub.mostrecent.provider"; + + public static final String TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"; + + // Number of free trial days for non-licensed servers. + public static final int FREE_TRIAL_DAYS = 30; + + // URL for project donations. + public static final String DONATION_URL = "http://subsonic.org/pages/android-donation.jsp"; + + public static final String ALBUM_ART_FILE = "albumart.jpg"; + + private Constants() { + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/FileUtil.java b/app/src/main/java/github/daneren2005/dsub/util/FileUtil.java new file mode 100644 index 00000000..990eae06 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/FileUtil.java @@ -0,0 +1,860 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.io.Serializable; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.Iterator; +import java.util.List; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; +import android.os.Environment; +import android.support.v4.content.ContextCompat; +import android.util.Log; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.service.MediaStoreService; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; + +/** + * @author Sindre Mehus + */ +public class FileUtil { + + private static final String TAG = FileUtil.class.getSimpleName(); + private static final String[] FILE_SYSTEM_UNSAFE = {"/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|"}; + private static final String[] FILE_SYSTEM_UNSAFE_DIR = {"\\", "..", ":", "\"", "?", "*", "<", ">", "|"}; + private static final List MUSIC_FILE_EXTENSIONS = Arrays.asList("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma"); + private static final List VIDEO_FILE_EXTENSIONS = Arrays.asList("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv"); + private static final List PLAYLIST_FILE_EXTENSIONS = Arrays.asList("m3u"); + private static File DEFAULT_MUSIC_DIR; + private static final Kryo kryo = new Kryo(); + private static HashMap entryLookup; + + static { + kryo.register(MusicDirectory.Entry.class); + kryo.register(Indexes.class); + kryo.register(Artist.class); + kryo.register(MusicFolder.class); + kryo.register(PodcastChannel.class); + kryo.register(Playlist.class); + kryo.register(Genre.class); + } + + public static File getAnySong(Context context) { + File dir = getMusicDirectory(context); + return getAnySong(context, dir); + } + private static File getAnySong(Context context, File dir) { + for(File file: dir.listFiles()) { + if(file.isDirectory()) { + return getAnySong(context, file); + } + + String extension = getExtension(file.getName()); + if(MUSIC_FILE_EXTENSIONS.contains(extension)) { + return file; + } + } + + return null; + } + + public static File getEntryFile(Context context, MusicDirectory.Entry entry) { + if(entry.isDirectory()) { + return getAlbumDirectory(context, entry); + } else { + return getSongFile(context, entry); + } + } + + public static File getSongFile(Context context, MusicDirectory.Entry song) { + File dir = getAlbumDirectory(context, song); + + StringBuilder fileName = new StringBuilder(); + Integer track = song.getTrack(); + if (track != null) { + if (track < 10) { + fileName.append("0"); + } + fileName.append(track).append("-"); + } + + fileName.append(fileSystemSafe(song.getTitle())).append("."); + + if(song.isVideo()) { + String videoPlayerType = Util.getVideoPlayerType(context); + if("hls".equals(videoPlayerType)) { + // HLS should be able to transcode to mp4 automatically + fileName.append("mp4"); + } else if("raw".equals(videoPlayerType)) { + // Download the original video without any transcoding + fileName.append(song.getSuffix()); + } + } else { + if (song.getTranscodedSuffix() != null) { + fileName.append(song.getTranscodedSuffix()); + } else { + fileName.append(song.getSuffix()); + } + } + + return new File(dir, fileName.toString()); + } + + public static File getPlaylistFile(Context context, String server, String name) { + File playlistDir = getPlaylistDirectory(context, server); + return new File(playlistDir, fileSystemSafe(name) + ".m3u"); + } + public static void writePlaylistFile(Context context, File file, MusicDirectory playlist) throws IOException { + FileWriter fw = new FileWriter(file); + BufferedWriter bw = new BufferedWriter(fw); + try { + fw.write("#EXTM3U\n"); + for (MusicDirectory.Entry e : playlist.getChildren()) { + String filePath = FileUtil.getSongFile(context, e).getAbsolutePath(); + if(! new File(filePath).exists()){ + String ext = FileUtil.getExtension(filePath); + String base = FileUtil.getBaseName(filePath); + filePath = base + ".complete." + ext; + } + fw.write(filePath + "\n"); + } + } catch(Exception e) { + Log.w(TAG, "Failed to save playlist: " + playlist.getName()); + } finally { + bw.close(); + fw.close(); + } + } + public static File getPlaylistDirectory(Context context) { + File playlistDir = new File(getSubsonicDirectory(context), "playlists"); + ensureDirectoryExistsAndIsReadWritable(playlistDir); + return playlistDir; + } + public static File getPlaylistDirectory(Context context, String server) { + File playlistDir = new File(getPlaylistDirectory(context), server); + ensureDirectoryExistsAndIsReadWritable(playlistDir); + return playlistDir; + } + + public static File getAlbumArtFile(Context context, MusicDirectory.Entry entry) { + File albumDir = getAlbumDirectory(context, entry); + File artFile; + File albumFile = getAlbumArtFile(albumDir); + File hexFile = getHexAlbumArtFile(context, albumDir); + if(albumDir.exists()) { + if(hexFile.exists()) { + hexFile.renameTo(albumFile); + } + artFile = albumFile; + } else { + artFile = hexFile; + } + return artFile; + } + + public static File getAlbumArtFile(File albumDir) { + return new File(albumDir, Constants.ALBUM_ART_FILE); + } + public static File getHexAlbumArtFile(Context context, File albumDir) { + return new File(getAlbumArtDirectory(context), Util.md5Hex(albumDir.getPath()) + ".jpeg"); + } + + public static Bitmap getAlbumArtBitmap(Context context, MusicDirectory.Entry entry, int size) { + File albumArtFile = getAlbumArtFile(context, entry); + if (albumArtFile.exists()) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(albumArtFile.getPath(), opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + + Bitmap bitmap = BitmapFactory.decodeFile(albumArtFile.getPath(), opt); + return bitmap == null ? null : getScaledBitmap(bitmap, size); + } + return null; + } + + public static File getAvatarDirectory(Context context) { + File avatarDir = new File(getSubsonicDirectory(context), "avatars"); + ensureDirectoryExistsAndIsReadWritable(avatarDir); + ensureDirectoryExistsAndIsReadWritable(new File(avatarDir, ".nomedia")); + return avatarDir; + } + + public static File getAvatarFile(Context context, String username) { + return new File(getAvatarDirectory(context), Util.md5Hex(username) + ".jpeg"); + } + + public static Bitmap getAvatarBitmap(Context context, String username, int size) { + File avatarFile = getAvatarFile(context, username); + if (avatarFile.exists()) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(avatarFile.getPath(), opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + + Bitmap bitmap = BitmapFactory.decodeFile(avatarFile.getPath(), opt); + return bitmap == null ? null : getScaledBitmap(bitmap, size, false); + } + return null; + } + + public static File getMiscDirectory(Context context) { + File dir = new File(getSubsonicDirectory(context), "misc"); + ensureDirectoryExistsAndIsReadWritable(dir); + ensureDirectoryExistsAndIsReadWritable(new File(dir, ".nomedia")); + return dir; + } + + public static File getMiscFile(Context context, String url) { + return new File(getMiscDirectory(context), Util.md5Hex(url) + ".jpeg"); + } + + public static Bitmap getMiscBitmap(Context context, String url, int size) { + File avatarFile = getMiscFile(context, url); + if (avatarFile.exists()) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(avatarFile.getPath(), opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + + Bitmap bitmap = BitmapFactory.decodeFile(avatarFile.getPath(), opt); + return bitmap == null ? null : getScaledBitmap(bitmap, size, false); + } + return null; + } + + public static Bitmap getSampledBitmap(byte[] bytes, int size) { + return getSampledBitmap(bytes, size, true); + } + public static Bitmap getSampledBitmap(byte[] bytes, int size, boolean allowUnscaled) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt); + if(bitmap == null) { + return null; + } else { + return getScaledBitmap(bitmap, size, allowUnscaled); + } + } + public static Bitmap getScaledBitmap(Bitmap bitmap, int size) { + return getScaledBitmap(bitmap, size, true); + } + public static Bitmap getScaledBitmap(Bitmap bitmap, int size, boolean allowUnscaled) { + // Don't waste time scaling if the difference is minor + // Large album arts still need to be scaled since displayed as is on now playing! + if(allowUnscaled && size < 400 && bitmap.getWidth() < (size * 1.1)) { + return bitmap; + } else { + return Bitmap.createScaledBitmap(bitmap, size, Util.getScaledHeight(bitmap, size), true); + } + } + + public static File getAlbumArtDirectory(Context context) { + File albumArtDir = new File(getSubsonicDirectory(context), "artwork"); + ensureDirectoryExistsAndIsReadWritable(albumArtDir); + ensureDirectoryExistsAndIsReadWritable(new File(albumArtDir, ".nomedia")); + return albumArtDir; + } + + public static File getArtistDirectory(Context context, Artist artist) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(artist.getName())); + return dir; + } + public static File getArtistDirectory(Context context, MusicDirectory.Entry artist) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(artist.getTitle())); + return dir; + } + + public static File getAlbumDirectory(Context context, MusicDirectory.Entry entry) { + File dir = null; + if (entry.getPath() != null) { + File f = new File(fileSystemSafeDir(entry.getPath())); + dir = new File(getMusicDirectory(context).getPath() + "/" + (entry.isDirectory() ? f.getPath() : f.getParent())); + } else { + MusicDirectory.Entry firstSong; + if(!Util.isOffline(context)) { + firstSong = lookupChild(context, entry, false); + if(firstSong != null) { + File songFile = FileUtil.getSongFile(context, firstSong); + dir = songFile.getParentFile(); + } + } + + if(dir == null) { + String artist = fileSystemSafe(entry.getArtist()); + String album = fileSystemSafe(entry.getAlbum()); + if("unnamed".equals(album)) { + album = fileSystemSafe(entry.getTitle()); + } + dir = new File(getMusicDirectory(context).getPath() + "/" + artist + "/" + album); + } + } + return dir; + } + + public static MusicDirectory.Entry lookupChild(Context context, MusicDirectory.Entry entry, boolean allowDir) { + // Initialize lookupMap if first time called + String lookupName = Util.getCacheName(context, "entryLookup"); + if(entryLookup == null) { + entryLookup = deserialize(context, lookupName, HashMap.class); + + // Create it if + if(entryLookup == null) { + entryLookup = new HashMap(); + } + } + + // Check if this lookup has already been done before + MusicDirectory.Entry child = entryLookup.get(entry.getId()); + if(child != null) { + return child; + } + + // Do a special lookup since 4.7+ doesn't match artist/album to entry.getPath + String s = Util.getRestUrl(context, null, false) + entry.getId(); + String cacheName = (Util.isTagBrowsing(context) ? "album-" : "directory-") + s.hashCode() + ".ser"; + MusicDirectory entryDir = FileUtil.deserialize(context, cacheName, MusicDirectory.class); + + if(entryDir != null) { + List songs = entryDir.getChildren(allowDir, true); + if(songs.size() > 0) { + child = songs.get(0); + entryLookup.put(entry.getId(), child); + serialize(context, entryLookup, lookupName); + return child; + } + } + + return null; + } + + public static String getPodcastPath(Context context, PodcastEpisode episode) { + return fileSystemSafe(episode.getArtist()) + "/" + fileSystemSafe(episode.getTitle()); + } + public static File getPodcastFile(Context context, String server) { + File dir = getPodcastDirectory(context); + return new File(dir.getPath() + "/" + fileSystemSafe(server)); + } + public static File getPodcastDirectory(Context context) { + File dir = new File(context.getCacheDir(), "podcasts"); + ensureDirectoryExistsAndIsReadWritable(dir); + return dir; + } + public static File getPodcastDirectory(Context context, PodcastChannel channel) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(channel.getName())); + return dir; + } + public static File getPodcastDirectory(Context context, String channel) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(channel)); + return dir; + } + + public static void createDirectoryForParent(File file) { + File dir = file.getParentFile(); + if (!dir.exists()) { + if (!dir.mkdirs()) { + Log.e(TAG, "Failed to create directory " + dir); + } + } + } + + private static File createDirectory(Context context, String name) { + File dir = new File(getSubsonicDirectory(context), name); + if (!dir.exists() && !dir.mkdirs()) { + Log.e(TAG, "Failed to create " + name); + } + return dir; + } + + public static File getSubsonicDirectory(Context context) { + return context.getExternalFilesDir(null); + } + + public static File getDefaultMusicDirectory(Context context) { + if(DEFAULT_MUSIC_DIR == null) { + File[] dirs; + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + dirs = context.getExternalMediaDirs(); + } else { + dirs = ContextCompat.getExternalFilesDirs(context, null); + } + + DEFAULT_MUSIC_DIR = new File(getBestDir(dirs), "music"); + Log.d(TAG, "Default: " + DEFAULT_MUSIC_DIR.getAbsolutePath()); + + if (!DEFAULT_MUSIC_DIR.exists() && !DEFAULT_MUSIC_DIR.mkdirs()) { + Log.e(TAG, "Failed to create default dir " + DEFAULT_MUSIC_DIR); + + // Some devices seem to have screwed up the new media directory API. Go figure. Try again with standard locations + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + dirs = ContextCompat.getExternalFilesDirs(context, null); + + DEFAULT_MUSIC_DIR = new File(getBestDir(dirs), "music"); + if (!DEFAULT_MUSIC_DIR.exists() && !DEFAULT_MUSIC_DIR.mkdirs()) { + Log.e(TAG, "Failed to create default dir " + DEFAULT_MUSIC_DIR); + } else { + Log.w(TAG, "Stupid OEM's messed up media dir API added in 5.0"); + } + } + } + } + + return DEFAULT_MUSIC_DIR; + } + private static File getBestDir(File[] dirs) { + // Past 5.0 we can query directly for SD Card + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + for(int i = 0; i < dirs.length; i++) { + if(dirs[i] != null && Environment.isExternalStorageRemovable(dirs[i])) { + return dirs[i]; + } + } + } + + // Before 5.0, we have to guess. Most of the time the SD card is last + for(int i = dirs.length - 1; i >= 0; i--) { + if(dirs[i] != null) { + return dirs[i]; + } + } + + // Should be impossible to be reached + return dirs[0]; + } + + public static File getMusicDirectory(Context context) { + String path = Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, getDefaultMusicDirectory(context).getPath()); + File dir = new File(path); + return ensureDirectoryExistsAndIsReadWritable(dir) ? dir : getDefaultMusicDirectory(context); + } + public static boolean deleteMusicDirectory(Context context) { + File musicDirectory = FileUtil.getMusicDirectory(context); + MediaStoreService mediaStore = new MediaStoreService(context); + return recursiveDelete(musicDirectory, mediaStore); + } + public static void deleteSerializedCache(Context context) { + for(File file: context.getCacheDir().listFiles()) { + if(file.getName().indexOf(".ser") != -1) { + file.delete(); + } + } + } + public static boolean deleteArtworkCache(Context context) { + File artDirectory = FileUtil.getAlbumArtDirectory(context); + return recursiveDelete(artDirectory); + } + public static boolean deleteAvatarCache(Context context) { + File artDirectory = FileUtil.getAvatarDirectory(context); + return recursiveDelete(artDirectory); + } + + public static boolean recursiveDelete(File dir) { + return recursiveDelete(dir, null); + } + public static boolean recursiveDelete(File dir, MediaStoreService mediaStore) { + if (dir != null && dir.exists()) { + File[] list = dir.listFiles(); + if(list != null) { + for(File file: list) { + if(file.isDirectory()) { + if(!recursiveDelete(file, mediaStore)) { + return false; + } + } else if(file.exists()) { + if(!file.delete()) { + return false; + } else if(mediaStore != null) { + mediaStore.deleteFromMediaStore(file); + } + } + } + } + return dir.delete(); + } + return false; + } + + public static void deleteEmptyDir(File dir) { + try { + File[] children = dir.listFiles(); + if(children == null) { + return; + } + + // No songs left in the folder + if (children.length == 1 && children[0].getPath().equals(FileUtil.getAlbumArtFile(dir).getPath())) { + Util.delete(children[0]); + children = dir.listFiles(); + } + + // Delete empty directory + if (children.length == 0) { + Util.delete(dir); + } + } catch(Exception e) { + Log.w(TAG, "Error while trying to delete empty dir", e); + } + } + + public static void unpinSong(Context context, File saveFile) { + // Don't try to unpin a song which isn't actually pinned + if(saveFile.getName().contains(".complete")) { + return; + } + + // Unpin file, rename to .complete + File completeFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) + + ".complete." + FileUtil.getExtension(saveFile.getName())); + + if(!saveFile.renameTo(completeFile)) { + Log.w(TAG, "Failed to upin " + saveFile + " to " + completeFile); + } else { + try { + new MediaStoreService(context).renameInMediaStore(completeFile, saveFile); + } catch(Exception e) { + Log.w(TAG, "Failed to write to media store"); + } + } + } + + public static boolean ensureDirectoryExistsAndIsReadWritable(File dir) { + if (dir == null) { + return false; + } + + if (dir.exists()) { + if (!dir.isDirectory()) { + Log.w(TAG, dir + " exists but is not a directory."); + return false; + } + } else { + if (dir.mkdirs()) { + Log.i(TAG, "Created directory " + dir); + } else { + Log.w(TAG, "Failed to create directory " + dir); + return false; + } + } + + if (!dir.canRead()) { + Log.w(TAG, "No read permission for directory " + dir); + return false; + } + + if (!dir.canWrite()) { + Log.w(TAG, "No write permission for directory " + dir); + return false; + } + return true; + } + public static boolean verifyCanWrite(File dir) { + if(ensureDirectoryExistsAndIsReadWritable(dir)) { + try { + File tmp = new File(dir, "checkWrite"); + tmp.createNewFile(); + if(tmp.exists()) { + if(tmp.delete()) { + return true; + } else { + Log.w(TAG, "Failed to delete temp file, retrying"); + + // This should never be reached since this is a file DSub created! + Thread.sleep(100L); + tmp = new File(dir, "checkWrite"); + if(tmp.delete()) { + return true; + } else { + Log.w(TAG, "Failed retry to delete temp file"); + return false; + } + } + } else { + Log.w(TAG, "Temp file does not actually exist"); + return false; + } + } catch(Exception e) { + Log.w(TAG, "Failed to create tmp file", e); + return false; + } + } else { + return false; + } + } + + /** + * Makes a given filename safe by replacing special characters like slashes ("/" and "\") + * with dashes ("-"). + * + * @param filename The filename in question. + * @return The filename with special characters replaced by hyphens. + */ + private static String fileSystemSafe(String filename) { + if (filename == null || filename.trim().length() == 0) { + return "unnamed"; + } + + for (String s : FILE_SYSTEM_UNSAFE) { + filename = filename.replace(s, "-"); + } + return filename; + } + + /** + * Makes a given filename safe by replacing special characters like colons (":") + * with dashes ("-"). + * + * @param path The path of the directory in question. + * @return The the directory name with special characters replaced by hyphens. + */ + private static String fileSystemSafeDir(String path) { + if (path == null || path.trim().length() == 0) { + return ""; + } + + for (String s : FILE_SYSTEM_UNSAFE_DIR) { + path = path.replace(s, "-"); + } + return path; + } + + /** + * Similar to {@link File#listFiles()}, but returns a sorted set. + * Never returns {@code null}, instead a warning is logged, and an empty set is returned. + */ + public static SortedSet listFiles(File dir) { + File[] files = dir.listFiles(); + if (files == null) { + Log.w(TAG, "Failed to list children for " + dir.getPath()); + return new TreeSet(); + } + + return new TreeSet(Arrays.asList(files)); + } + + public static SortedSet listMediaFiles(File dir) { + SortedSet files = listFiles(dir); + Iterator iterator = files.iterator(); + while (iterator.hasNext()) { + File file = iterator.next(); + if (!file.isDirectory() && !isMediaFile(file)) { + iterator.remove(); + } + } + return files; + } + + private static boolean isMediaFile(File file) { + String extension = getExtension(file.getName()); + return MUSIC_FILE_EXTENSIONS.contains(extension) || VIDEO_FILE_EXTENSIONS.contains(extension); + } + + public static boolean isMusicFile(File file) { + String extension = getExtension(file.getName()); + return MUSIC_FILE_EXTENSIONS.contains(extension); + } + public static boolean isVideoFile(File file) { + String extension = getExtension(file.getName()); + return VIDEO_FILE_EXTENSIONS.contains(extension); + } + + public static boolean isPlaylistFile(File file) { + String extension = getExtension(file.getName()); + return PLAYLIST_FILE_EXTENSIONS.contains(extension); + } + + /** + * Returns the extension (the substring after the last dot) of the given file. The dot + * is not included in the returned extension. + * + * @param name The filename in question. + * @return The extension, or an empty string if no extension is found. + */ + public static String getExtension(String name) { + int index = name.lastIndexOf('.'); + return index == -1 ? "" : name.substring(index + 1).toLowerCase(); + } + + /** + * Returns the base name (the substring before the last dot) of the given file. The dot + * is not included in the returned basename. + * + * @param name The filename in question. + * @return The base name, or an empty string if no basename is found. + */ + public static String getBaseName(String name) { + int index = name.lastIndexOf('.'); + return index == -1 ? name : name.substring(0, index); + } + + public static Pair getUsedSize(Context context, File file) { + long number = 0L; + long size = 0L; + + if(file.isFile()) { + if(isMediaFile(file)) { + return new Pair(1L, file.length()); + } else { + return new Pair(0L, 0L); + } + } else { + for (File child : FileUtil.listFiles(file)) { + Pair pair = getUsedSize(context, child); + number += pair.getFirst(); + size += pair.getSecond(); + } + return new Pair(number, size); + } + } + + public static boolean serialize(Context context, T obj, String fileName) { + Output out = null; + try { + RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "rw"); + out = new Output(new FileOutputStream(file.getFD())); + synchronized (kryo) { + kryo.writeObject(out, obj); + } + return true; + } catch (Throwable x) { + Log.w(TAG, "Failed to serialize object to " + fileName); + return false; + } finally { + Util.close(out); + } + } + + public static T deserialize(Context context, String fileName, Class tClass) { + return deserialize(context, fileName, tClass, 0); + } + + public static T deserialize(Context context, String fileName, Class tClass, int hoursOld) { + Input in = null; + try { + File file = new File(context.getCacheDir(), fileName); + if(!file.exists()) { + return null; + } + + if(hoursOld != 0) { + Date fileDate = new Date(file.lastModified()); + // Convert into hours + long age = (new Date().getTime() - fileDate.getTime()) / 1000 / 3600; + if(age > hoursOld) { + return null; + } + } + + RandomAccessFile randomFile = new RandomAccessFile(file, "r"); + + in = new Input(new FileInputStream(randomFile.getFD())); + synchronized (kryo) { + T result = kryo.readObject(in, tClass); + return result; + } + } catch(FileNotFoundException e) { + // Different error message + Log.w(TAG, "No serialization for object from " + fileName); + return null; + } catch (Throwable x) { + Log.w(TAG, "Failed to deserialize object from " + fileName); + return null; + } finally { + Util.close(in); + } + } + + public static boolean serializeCompressed(Context context, T obj, String fileName) { + Output out = null; + try { + RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "rw"); + out = new Output(new DeflaterOutputStream(new FileOutputStream(file.getFD()))); + synchronized (kryo) { + kryo.writeObject(out, obj); + } + return true; + } catch (Throwable x) { + Log.w(TAG, "Failed to serialize compressed object to " + fileName); + return false; + } finally { + Util.close(out); + } + } + + @TargetApi(Build.VERSION_CODES.GINGERBREAD) + public static T deserializeCompressed(Context context, String fileName, Class tClass) { + Input in = null; + try { + RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "r"); + + in = new Input(new InflaterInputStream(new FileInputStream(file.getFD()))); + synchronized (kryo) { + T result = kryo.readObject(in, tClass); + return result; + } + } catch(FileNotFoundException e) { + // Different error message + Log.w(TAG, "No serialization compressed for object from " + fileName); + return null; + } catch (Throwable x) { + Log.w(TAG, "Failed to deserialize compressed object from " + fileName); + return null; + } finally { + Util.close(in); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java b/app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java new file mode 100644 index 00000000..1a0e8242 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java @@ -0,0 +1,600 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.media.RemoteControlClient; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.util.Log; +import android.support.v4.util.LruCache; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; + +/** + * Asynchronous loading of images, with caching. + *

+ * There should normally be only one instance of this class. + * + * @author Sindre Mehus + */ +public class ImageLoader { + private static final String TAG = ImageLoader.class.getSimpleName(); + + private Context context; + private LruCache cache; + private Handler handler; + private Bitmap nowPlaying; + private final int imageSizeDefault; + private final int imageSizeLarge; + private final int avatarSizeDefault; + private boolean clearingCache = false; + + private final static int[] COLORS = {0xFF33B5E5, 0xFFAA66CC, 0xFF99CC00, 0xFFFFBB33, 0xFFFF4444}; + + public ImageLoader(Context context) { + this.context = context; + handler = new Handler(Looper.getMainLooper()); + final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + final int cacheSize = maxMemory / 4; + + // Determine the density-dependent image sizes. + imageSizeDefault = context.getResources().getDrawable(R.drawable.unknown_album).getIntrinsicHeight(); + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + imageSizeLarge = Math.round(Math.min(metrics.widthPixels, metrics.heightPixels)); + avatarSizeDefault = context.getResources().getDrawable(R.drawable.ic_social_person).getIntrinsicHeight(); + + cache = new LruCache(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + return bitmap.getRowBytes() * bitmap.getHeight() / 1024; + } + + @Override + protected void entryRemoved(boolean evicted, String key, Bitmap oldBitmap, Bitmap newBitmap) { + if(evicted) { + if(oldBitmap != nowPlaying && key.indexOf("unknown") != 0 || clearingCache) { + if(sizeOf("", oldBitmap) > 500) { + oldBitmap.recycle(); + } + } else { + cache.put(key, oldBitmap); + } + } + } + }; + } + + public void clearCache() { + nowPlaying = null; + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + clearingCache = true; + cache.evictAll(); + clearingCache = false; + return null; + } + }.execute(); + } + + private Bitmap getUnknownImage(MusicDirectory.Entry entry, int size) { + String key; + int color; + if(entry == null) { + key = getKey("unknown", size); + color = COLORS[0]; + + return getUnknownImage(key, size, color, null, null); + } else { + key = getKey(entry.getId() + "unknown", size); + String hash; + if(entry.getAlbum() != null) { + hash = entry.getAlbum(); + } else if(entry.getArtist() != null) { + hash = entry.getArtist(); + } else { + hash = entry.getId(); + } + color = COLORS[Math.abs(hash.hashCode()) % COLORS.length]; + + return getUnknownImage(key, size, color, entry.getAlbum(), entry.getArtist()); + } + } + private Bitmap getUnknownImage(String key, int size, int color, String topText, String bottomText) { + Bitmap bitmap = cache.get(key); + if(bitmap == null) { + bitmap = createUnknownImage(size, color, topText, bottomText); + cache.put(key, bitmap); + } + + return bitmap; + } + private Bitmap createUnknownImage(int size, int primaryColor, String topText, String bottomText) { + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint color = new Paint(); + color.setColor(primaryColor); + canvas.drawRect(0, 0, size, size * 2.0f / 3.0f, color); + + color.setShader(new LinearGradient(0, 0, 0, size / 3.0f, Color.rgb(82, 82, 82), Color.BLACK, Shader.TileMode.MIRROR)); + canvas.drawRect(0, size * 2.0f / 3.0f, size, size, color); + + if(topText != null || bottomText != null) { + Paint font = new Paint(); + font.setFlags(Paint.ANTI_ALIAS_FLAG); + font.setColor(Color.WHITE); + font.setTextSize(3.0f + size * 0.07f); + + if(topText != null) { + canvas.drawText(topText, size * 0.05f, size * 0.6f, font); + } + + if(bottomText != null) { + canvas.drawText(bottomText, size * 0.05f, size * 0.8f, font); + } + } + + return bitmap; + } + + public Bitmap getCachedImage(Context context, MusicDirectory.Entry entry, boolean large) { + int size = large ? imageSizeLarge : imageSizeDefault; + if(entry == null || entry.getCoverArt() == null) { + return getUnknownImage(entry, size); + } + + Bitmap bitmap = cache.get(getKey(entry.getCoverArt(), size)); + if(bitmap == null || bitmap.isRecycled()) { + bitmap = FileUtil.getAlbumArtBitmap(context, entry, size); + String key = getKey(entry.getCoverArt(), size); + cache.put(key, bitmap); + cache.get(key); + } + + return bitmap; + } + + public SilentBackgroundTask loadImage(View view, MusicDirectory.Entry entry, boolean large, boolean crossfade) { + // TODO: If we know this a artist, try to load artist info instead + int size = large ? imageSizeLarge : imageSizeDefault; + if(entry != null && !entry.isAlbum() && ServerInfo.checkServerVersion(context, "1.11") && !Util.isOffline(context)) { + SilentBackgroundTask task = new ArtistImageTask(view.getContext(), entry, size, imageSizeLarge, large, view, crossfade); + task.execute(); + return task; + } else if(entry != null && entry.getCoverArt() == null && entry.isDirectory() && !Util.isOffline(context)) { + // Try to lookup child cover art + MusicDirectory.Entry firstChild = FileUtil.lookupChild(context, entry, true); + if(firstChild != null) { + entry.setCoverArt(firstChild.getCoverArt()); + } + } + + Bitmap bitmap; + if (entry == null || entry.getCoverArt() == null) { + bitmap = getUnknownImage(entry, size); + setImage(view, Util.createDrawableFromBitmap(context, bitmap), crossfade); + return null; + } + + bitmap = cache.get(getKey(entry.getCoverArt(), size)); + if (bitmap != null && !bitmap.isRecycled()) { + final Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + setImage(view, drawable, crossfade); + if(large) { + nowPlaying = bitmap; + } + return null; + } + + if (!large) { + setImage(view, Util.createDrawableFromBitmap(context, null), false); + } + ImageTask task = new ViewImageTask(view.getContext(), entry, size, imageSizeLarge, large, view, crossfade); + task.execute(); + return task; + } + + public SilentBackgroundTask loadImage(View view, String url, boolean large) { + Bitmap bitmap; + int size = large ? imageSizeLarge : imageSizeDefault; + if (url == null) { + String key = getKey(url + "unknown", size); + int color = COLORS[Math.abs(key.hashCode()) % COLORS.length]; + bitmap = getUnknownImage(key, size, color, null, null); + setImage(view, Util.createDrawableFromBitmap(context, bitmap), true); + return null; + } + + bitmap = cache.get(getKey(url, size)); + if (bitmap != null && !bitmap.isRecycled()) { + final Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + setImage(view, drawable, true); + return null; + } + + SilentBackgroundTask task = new ViewUrlTask(view.getContext(), view, url, size); + task.execute(); + return task; + } + + public SilentBackgroundTask loadImage(Context context, RemoteControlClient remoteControl, MusicDirectory.Entry entry) { + Bitmap bitmap; + if (entry == null || entry.getCoverArt() == null) { + bitmap = getUnknownImage(entry, imageSizeLarge); + setImage(remoteControl, Util.createDrawableFromBitmap(context, bitmap)); + return null; + } + + bitmap = cache.get(getKey(entry.getCoverArt(), imageSizeLarge)); + if (bitmap != null && !bitmap.isRecycled()) { + Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + setImage(remoteControl, drawable); + return null; + } + + setImage(remoteControl, Util.createDrawableFromBitmap(context, null)); + ImageTask task = new RemoteControlClientImageTask(context, entry, imageSizeLarge, imageSizeLarge, false, remoteControl); + task.execute(); + return task; + } + + public SilentBackgroundTask loadAvatar(Context context, ImageView view, String username) { + Bitmap bitmap = cache.get(username); + if (bitmap != null && !bitmap.isRecycled()) { + Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + view.setImageDrawable(drawable); + return null; + } + + SilentBackgroundTask task = new AvatarTask(context, view, username); + task.execute(); + return task; + } + + private String getKey(String coverArtId, int size) { + return coverArtId + size; + } + + private void setImage(View view, final Drawable drawable, boolean crossfade) { + if (view instanceof TextView) { + // Cross-fading is not implemented for TextView since it's not in use. It would be easy to add it, though. + TextView textView = (TextView) view; + textView.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null); + } else if (view instanceof ImageView) { + final ImageView imageView = (ImageView) view; + if (crossfade && drawable != null) { + Drawable existingDrawable = imageView.getDrawable(); + if (existingDrawable == null) { + Bitmap emptyImage; + if(drawable.getIntrinsicWidth() > 0 && drawable.getIntrinsicHeight() > 0) { + emptyImage = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + } else { + emptyImage = Bitmap.createBitmap(imageSizeDefault, imageSizeDefault, Bitmap.Config.ARGB_8888); + } + existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); + } else if(existingDrawable instanceof TransitionDrawable) { + // This should only ever be used if user is skipping through many songs quickly + TransitionDrawable tmp = (TransitionDrawable) existingDrawable; + existingDrawable = tmp.getDrawable(tmp.getNumberOfLayers() - 1); + } + if(existingDrawable != null && drawable != null) { + Drawable[] layers = new Drawable[]{existingDrawable, drawable}; + final TransitionDrawable transitionDrawable = new TransitionDrawable(layers); + imageView.setImageDrawable(transitionDrawable); + transitionDrawable.startTransition(250); + + // Get rid of transition drawable after transition occurs + handler.postDelayed(new Runnable() { + @Override + public void run() { + // Only execute if still on same transition drawable + if (imageView.getDrawable() == transitionDrawable) { + imageView.setImageDrawable(drawable); + } + } + }, 500L); + } else { + imageView.setImageDrawable(drawable); + } + } else { + imageView.setImageDrawable(drawable); + } + } + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + private void setImage(RemoteControlClient remoteControl, Drawable drawable) { + if(remoteControl != null && drawable != null) { + Bitmap origBitmap = ((BitmapDrawable)drawable).getBitmap(); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && origBitmap != null) { + origBitmap = origBitmap.copy(origBitmap.getConfig(), false); + } + if ( origBitmap != null && !origBitmap.isRecycled()) { + remoteControl.editMetadata(false).putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, origBitmap).apply(); + } else { + if(origBitmap != null) { + Log.e(TAG, "Tried to load a recycled bitmap."); + } + remoteControl.editMetadata(false) + .putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, null) + .apply(); + } + } + } + + public abstract class ImageTask extends SilentBackgroundTask { + private final Context mContext; + private final MusicDirectory.Entry mEntry; + private final int mSize; + private final int mSaveSize; + private final boolean mIsNowPlaying; + protected Drawable mDrawable; + + public ImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying) { + super(context); + mContext = context; + mEntry = entry; + mSize = size; + mSaveSize = saveSize; + mIsNowPlaying = isNowPlaying; + } + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + Bitmap bitmap = musicService.getCoverArt(mContext, mEntry, mSize, null, this); + String key = getKey(mEntry.getCoverArt(), mSize); + cache.put(key, bitmap); + // Make sure key is the most recently "used" + cache.get(key); + if(mIsNowPlaying) { + nowPlaying = bitmap; + } + + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + } catch (Throwable x) { + Log.e(TAG, "Failed to download album art.", x); + cancelled.set(true); + } + + return null; + } + } + + private class ViewImageTask extends ImageTask { + protected boolean mCrossfade; + private View mView; + + public ViewImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying, View view, boolean crossfade) { + super(context, entry, size, saveSize, isNowPlaying); + + mView = view; + mCrossfade = crossfade; + } + + @Override + protected void done(Void result) { + setImage(mView, mDrawable, mCrossfade); + } + } + + private class RemoteControlClientImageTask extends ImageTask { + private RemoteControlClient mRemoteControl; + + public RemoteControlClientImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying, RemoteControlClient remoteControl) { + super(context, entry, size, saveSize, isNowPlaying); + + mRemoteControl = remoteControl; + } + + @Override + protected void done(Void result) { + setImage(mRemoteControl, mDrawable); + } + } + + private class ArtistImageTask extends SilentBackgroundTask { + private final Context mContext; + private final MusicDirectory.Entry mEntry; + private final int mSize; + private final int mSaveSize; + private final boolean mIsNowPlaying; + private Drawable mDrawable; + private boolean mCrossfade; + private View mView; + + private SilentBackgroundTask subTask; + + public ArtistImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying, View view, boolean crossfade) { + super(context); + mContext = context; + mEntry = entry; + mSize = size; + mSaveSize = saveSize; + mIsNowPlaying = isNowPlaying; + mView = view; + mCrossfade = crossfade; + } + + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + ArtistInfo artistInfo = musicService.getArtistInfo(mEntry.getId(), false, true, mContext, null); + String url = artistInfo.getImageUrl(); + + // Figure out whether we are going to get a artist image or the standard image + if(url != null && !"".equals(url.trim())) { + // If getting the artist image fails for any reason, retry for the standard version + subTask = new ViewUrlTask(mContext, mView, url, mSize) { + @Override + protected void failedToDownload() { + // Call loadImage so we can take advantage of all of it's logic checks + loadImage(mView, mEntry, mSize == imageSizeLarge, mCrossfade); + + // Delete subTask so it doesn't get called in done + subTask = null; + } + }; + } else { + if (mEntry != null && mEntry.getCoverArt() == null && mEntry.isDirectory() && !Util.isOffline(context)) { + // Try to lookup child cover art + MusicDirectory.Entry firstChild = FileUtil.lookupChild(context, mEntry, true); + if (firstChild != null) { + mEntry.setCoverArt(firstChild.getCoverArt()); + } + } + + if (mEntry != null && mEntry.getCoverArt() != null) { + subTask = new ViewImageTask(mContext, mEntry, mSize, mSaveSize, mIsNowPlaying, mView, mCrossfade); + } else { + // If entry is null as well, we need to just set as a blank image + Bitmap bitmap = getUnknownImage(mEntry, mSize); + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + return null; + } + } + + // Execute whichever way we decided to go + subTask.doInBackground(); + return null; + } + + @Override + public void done(Void result) { + if(subTask != null) { + subTask.done(result); + } else if(mDrawable != null) { + setImage(mView, mDrawable, mCrossfade); + } + } + } + + private class ViewUrlTask extends SilentBackgroundTask { + private final Context mContext; + private final String mUrl; + private final ImageView mView; + private Drawable mDrawable; + private int mSize; + + public ViewUrlTask(Context context, View view, String url, int size) { + super(context); + mContext = context; + mView = (ImageView) view; + mUrl = url; + mSize = size; + } + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + Bitmap bitmap = musicService.getBitmap(mUrl, mSize, mContext, null, this); + if(bitmap != null) { + String key = getKey(mUrl, mSize); + cache.put(key, bitmap); + // Make sure key is the most recently "used" + cache.get(key); + + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + } + } catch (Throwable x) { + Log.e(TAG, "Failed to download from url " + mUrl, x); + cancelled.set(true); + } + + return null; + } + + @Override + protected void done(Void result) { + if(mDrawable != null) { + mView.setImageDrawable(mDrawable); + } else { + failedToDownload(); + } + } + + protected void failedToDownload() { + + } + } + + private class AvatarTask extends SilentBackgroundTask { + private final Context mContext; + private final String mUsername; + private final ImageView mView; + private Drawable mDrawable; + + public AvatarTask(Context context, ImageView view, String username) { + super(context); + mContext = context; + mView = view; + mUsername = username; + } + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + Bitmap bitmap = musicService.getAvatar(mUsername, avatarSizeDefault, mContext, null, this); + if(bitmap != null) { + cache.put(mUsername, bitmap); + // Make sure key is the most recently "used" + cache.get(mUsername); + + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + } + } catch (Throwable x) { + Log.e(TAG, "Failed to download album art.", x); + cancelled.set(true); + } + + return null; + } + + @Override + protected void done(Void result) { + if(mDrawable != null) { + mView.setImageDrawable(mDrawable); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java b/app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java new file mode 100644 index 00000000..116da816 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java @@ -0,0 +1,73 @@ +package github.daneren2005.dsub.util; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.DialogInterface; + +import github.daneren2005.dsub.activity.SubsonicActivity; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public abstract class LoadingTask extends BackgroundTask { + + private final Activity tabActivity; + private ProgressDialog loading; + private final boolean cancellable; + + public LoadingTask(Activity activity) { + super(activity); + tabActivity = activity; + this.cancellable = true; + } + public LoadingTask(Activity activity, final boolean cancellable) { + super(activity); + tabActivity = activity; + this.cancellable = cancellable; + } + + @Override + public void execute() { + loading = ProgressDialog.show(tabActivity, "", "Loading. Please Wait...", true, cancellable, new DialogInterface.OnCancelListener() { + public void onCancel(DialogInterface dialog) { + cancel(); + } + }); + + queue.offer(task = new Task() { + @Override + public void onDone(T result) { + if(loading.isShowing()) { + loading.dismiss(); + } + done(result); + } + + @Override + public void onError(Throwable t) { + if(loading.isShowing()) { + loading.dismiss(); + } + error(t); + } + }); + } + + @Override + public boolean isCancelled() { + return (tabActivity instanceof SubsonicActivity && ((SubsonicActivity) tabActivity).isDestroyedCompat()) || cancelled.get(); + } + + @Override + public void updateProgress(final String message) { + if(!cancelled.get()) { + getHandler().post(new Runnable() { + @Override + public void run() { + loading.setMessage(message); + } + }); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java b/app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java new file mode 100644 index 00000000..9aa54c4b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java @@ -0,0 +1,181 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.os.Build; +import android.support.v7.media.MediaRouteProvider; +import android.support.v7.media.MediaRouteSelector; +import android.support.v7.media.MediaRouter; +import android.util.Log; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GooglePlayServicesUtil; + +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.provider.DLNARouteProvider; +import github.daneren2005.dsub.provider.JukeboxRouteProvider; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.RemoteController; +import github.daneren2005.dsub.util.compat.CastCompat; + +import static android.support.v7.media.MediaRouter.RouteInfo; + +/** + * Created by owner on 2/8/14. + */ +public class MediaRouteManager extends MediaRouter.Callback { + private static final String TAG = MediaRouteManager.class.getSimpleName(); + private static boolean castAvailable = false; + + private DownloadService downloadService; + private MediaRouter router; + private MediaRouteSelector selector; + private List providers = new ArrayList(); + private List onlineProviders = new ArrayList(); + + static { + try { + CastCompat.checkAvailable(); + castAvailable = true; + } catch(Throwable t) { + castAvailable = false; + } + } + + public MediaRouteManager(DownloadService downloadService) { + this.downloadService = downloadService; + router = MediaRouter.getInstance(downloadService); + + // Check if play services is available + int result = GooglePlayServicesUtil.isGooglePlayServicesAvailable(downloadService); + if(result != ConnectionResult.SUCCESS){ + Log.w(TAG, "No play services, failed with result: " + result); + castAvailable = false; + } + + addProviders(); + buildSelector(); + } + + public void destroy() { + for(MediaRouteProvider provider: providers) { + router.removeProvider(provider); + } + } + + @Override + public void onRouteSelected(MediaRouter router, RouteInfo info) { + if(castAvailable) { + RemoteController controller = CastCompat.getController(downloadService, info); + if(controller != null) { + downloadService.setRemoteEnabled(RemoteControlState.CHROMECAST, controller); + } + } + + if(downloadService.isRemoteEnabled()) { + downloadService.registerRoute(router); + } + } + @Override + public void onRouteUnselected(MediaRouter router, RouteInfo info) { + if(downloadService.isRemoteEnabled()) { + downloadService.unregisterRoute(router); + } + + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + } + + public void setDefaultRoute() { + router.selectRoute(router.getDefaultRoute()); + } + + public void startScan() { + router.addCallback(selector, this, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN); + } + public void stopScan() { + router.removeCallback(this); + } + + public MediaRouteSelector getSelector() { + return selector; + } + + public RouteInfo getSelectedRoute() { + return router.getSelectedRoute(); + } + public RouteInfo getRouteForId(String id) { + if(id == null) { + return null; + } + + // Try to find matching id + for(RouteInfo info: router.getRoutes()) { + if(id.equals(info.getId())) { + router.selectRoute(info); + return info; + } + } + + return null; + } + public RemoteController getRemoteController(RouteInfo info) { + if(castAvailable) { + return CastCompat.getController(downloadService, info); + } else { + return null; + } + } + + public void addOnlineProviders() { + JukeboxRouteProvider jukeboxProvider = new JukeboxRouteProvider(downloadService); + router.addProvider(jukeboxProvider); + providers.add(jukeboxProvider); + onlineProviders.add(jukeboxProvider); + } + public void removeOnlineProviders() { + for(MediaRouteProvider provider: onlineProviders) { + router.removeProvider(provider); + } + } + + private void addProviders() { + if(!Util.isOffline(downloadService)) { + addOnlineProviders(); + } + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + DLNARouteProvider dlnaProvider = new DLNARouteProvider(downloadService); + router.addProvider(dlnaProvider); + providers.add(dlnaProvider); + } + } + public void buildSelector() { + MediaRouteSelector.Builder builder = new MediaRouteSelector.Builder(); + if(UserUtil.canJukebox()) { + builder.addControlCategory(JukeboxRouteProvider.CATEGORY_JUKEBOX_ROUTE); + } + if(castAvailable) { + builder.addControlCategory(CastCompat.getCastControlCategory()); + } + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + builder.addControlCategory(DLNARouteProvider.CATEGORY_DLNA); + } + selector = builder.build(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Notifications.java b/app/src/main/java/github/daneren2005/dsub/util/Notifications.java new file mode 100644 index 00000000..d078d77e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Notifications.java @@ -0,0 +1,348 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.Handler; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.RemoteViews; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.provider.DSubWidgetProvider; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; + +public final class Notifications { + private static final String TAG = Notifications.class.getSimpleName(); + + // Notification IDs. + public static final int NOTIFICATION_ID_PLAYING = 100; + public static final int NOTIFICATION_ID_DOWNLOADING = 102; + public static final String NOTIFICATION_SYNC_GROUP = "github.daneren2005.dsub.sync"; + + private static boolean playShowing = false; + private static boolean downloadShowing = false; + private static boolean downloadForeground = false; + + private final static Pair NOTIFICATION_TEXT_COLORS = new Pair(); + + public static void showPlayingNotification(final Context context, final DownloadService downloadService, final Handler handler, MusicDirectory.Entry song) { + // Set the icon, scrolling text and timestamp + final Notification notification = new Notification(R.drawable.stat_notify_playing, song.getTitle(), System.currentTimeMillis()); + + final boolean playing = downloadService.getPlayerState() == PlayerState.STARTED; + if(playing) { + notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT; + } + boolean remote = downloadService.isRemoteEnabled(); + if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.JELLY_BEAN){ + RemoteViews expandedContentView = new RemoteViews(context.getPackageName(), R.layout.notification_expanded); + setupViews(expandedContentView ,context, song, true, playing, remote); + notification.bigContentView = expandedContentView; + notification.priority = Notification.PRIORITY_HIGH; + } + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + notification.visibility = Notification.VISIBILITY_PUBLIC; + } + + RemoteViews smallContentView = new RemoteViews(context.getPackageName(), R.layout.notification); + setupViews(smallContentView, context, song, false, playing, remote); + notification.contentView = smallContentView; + + Intent notificationIntent = new Intent(context, SubsonicFragmentActivity.class); + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD, true); + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + notification.contentIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0); + + playShowing = true; + if(downloadForeground && downloadShowing) { + downloadForeground = false; + handler.post(new Runnable() { + @Override + public void run() { + downloadService.stopForeground(true); + showDownloadingNotification(context, downloadService, handler, downloadService.getCurrentDownloading(), downloadService.getBackgroundDownloads().size()); + downloadService.startForeground(NOTIFICATION_ID_PLAYING, notification); + } + }); + } else { + handler.post(new Runnable() { + @Override + public void run() { + if(playing) { + downloadService.startForeground(NOTIFICATION_ID_PLAYING, notification); + } else { + playShowing = false; + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + downloadService.stopForeground(true); + notificationManager.notify(NOTIFICATION_ID_PLAYING, notification); + } + } + }); + } + + // Update widget + DSubWidgetProvider.notifyInstances(context, downloadService, playing); + } + + private static void setupViews(RemoteViews rv, Context context, MusicDirectory.Entry song, boolean expanded, boolean playing, boolean remote){ + + // Use the same text for the ticker and the expanded notification + String title = song.getTitle(); + String arist = song.getArtist(); + String album = song.getAlbum(); + + // Set the album art. + try { + ImageLoader imageLoader = SubsonicActivity.getStaticImageLoader(context); + Bitmap bitmap = null; + if(imageLoader != null) { + bitmap = imageLoader.getCachedImage(context, song, false); + } + if (bitmap == null) { + // set default album art + rv.setImageViewResource(R.id.notification_image, R.drawable.unknown_album); + } else { + rv.setImageViewBitmap(R.id.notification_image, bitmap); + } + } catch (Exception x) { + Log.w(TAG, "Failed to get notification cover art", x); + rv.setImageViewResource(R.id.notification_image, R.drawable.unknown_album); + } + + // set the text for the notifications + rv.setTextViewText(R.id.notification_title, title); + rv.setTextViewText(R.id.notification_artist, arist); + rv.setTextViewText(R.id.notification_album, album); + + boolean persistent = Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_PERSISTENT_NOTIFICATION, false); + if(persistent) { + if(expanded) { + rv.setImageViewResource(R.id.control_pause, playing ? R.drawable.notification_pause : R.drawable.notification_play); + } else { + rv.setImageViewResource(R.id.control_previous, playing ? R.drawable.notification_pause : R.drawable.notification_play); + rv.setImageViewResource(R.id.control_pause, R.drawable.notification_next); + rv.setImageViewResource(R.id.control_next, R.drawable.notification_close); + } + } + + // Create actions for media buttons + PendingIntent pendingIntent; + int previous = 0, pause = 0, next = 0, close = 0; + if(persistent && !expanded) { + pause = R.id.control_previous; + next = R.id.control_pause; + close = R.id.control_next; + } else { + previous = R.id.control_previous; + pause = R.id.control_pause; + next = R.id.control_next; + } + + if((remote || persistent) && close == 0 && expanded) { + close = R.id.notification_close; + rv.setViewVisibility(close, View.VISIBLE); + } + + if(previous > 0) { + Intent prevIntent = new Intent("KEYCODE_MEDIA_PREVIOUS"); + prevIntent.setComponent(new ComponentName(context, DownloadService.class)); + prevIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)); + pendingIntent = PendingIntent.getService(context, 0, prevIntent, 0); + rv.setOnClickPendingIntent(previous, pendingIntent); + } + if(pause > 0) { + if(playing) { + Intent pauseIntent = new Intent("KEYCODE_MEDIA_PLAY_PAUSE"); + pauseIntent.setComponent(new ComponentName(context, DownloadService.class)); + pauseIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + pendingIntent = PendingIntent.getService(context, 0, pauseIntent, 0); + rv.setOnClickPendingIntent(pause, pendingIntent); + } else { + Intent prevIntent = new Intent("KEYCODE_MEDIA_START"); + prevIntent.setComponent(new ComponentName(context, DownloadService.class)); + prevIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)); + pendingIntent = PendingIntent.getService(context, 0, prevIntent, 0); + rv.setOnClickPendingIntent(pause, pendingIntent); + } + } + if(next > 0) { + Intent nextIntent = new Intent("KEYCODE_MEDIA_NEXT"); + nextIntent.setComponent(new ComponentName(context, DownloadService.class)); + nextIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)); + pendingIntent = PendingIntent.getService(context, 0, nextIntent, 0); + rv.setOnClickPendingIntent(next, pendingIntent); + } + if(close > 0) { + Intent prevIntent = new Intent("KEYCODE_MEDIA_STOP"); + prevIntent.setComponent(new ComponentName(context, DownloadService.class)); + prevIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_STOP)); + pendingIntent = PendingIntent.getService(context, 0, prevIntent, 0); + rv.setOnClickPendingIntent(close, pendingIntent); + } + } + + public static void hidePlayingNotification(final Context context, final DownloadService downloadService, Handler handler) { + playShowing = false; + + // Remove notification and remove the service from the foreground + handler.post(new Runnable() { + @Override + public void run() { + downloadService.stopForeground(true); + } + }); + + // Get downloadNotification in foreground if playing + if(downloadShowing) { + showDownloadingNotification(context, downloadService, handler, downloadService.getCurrentDownloading(), downloadService.getBackgroundDownloads().size()); + } + + // Update widget + DSubWidgetProvider.notifyInstances(context, downloadService, false); + } + + public static void showDownloadingNotification(final Context context, final DownloadService downloadService, Handler handler, DownloadFile file, int size) { + Intent cancelIntent = new Intent(context, DownloadService.class); + cancelIntent.setAction(DownloadService.CANCEL_DOWNLOADS); + PendingIntent cancelPI = PendingIntent.getService(context, 0, cancelIntent, 0); + + String currentDownloading, currentSize; + if(file != null) { + currentDownloading = file.getSong().getTitle(); + currentSize = Util.formatLocalizedBytes(file.getEstimatedSize(), context); + } else { + currentDownloading = "none"; + currentSize = "0"; + } + + NotificationCompat.Builder builder; + builder = new NotificationCompat.Builder(context) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(context.getResources().getString(R.string.download_downloading_title, size)) + .setContentText(context.getResources().getString(R.string.download_downloading_summary, currentDownloading)) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(context.getResources().getString(R.string.download_downloading_summary_expanded, currentDownloading, currentSize))) + .setProgress(10, 5, true) + .setOngoing(true) + .addAction(R.drawable.notification_close, + context.getResources().getString(R.string.common_cancel), + cancelPI); + + Intent notificationIntent = new Intent(context, SubsonicFragmentActivity.class); + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD, true); + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD_VIEW, true); + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + builder.setContentIntent(PendingIntent.getActivity(context, 1, notificationIntent, 0)); + + final Notification notification = builder.build(); + downloadShowing = true; + if(playShowing) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID_DOWNLOADING, notification); + } else { + downloadForeground = true; + handler.post(new Runnable() { + @Override + public void run() { + downloadService.startForeground(NOTIFICATION_ID_DOWNLOADING, notification); + } + }); + } + + } + public static void hideDownloadingNotification(final Context context, final DownloadService downloadService, Handler handler) { + downloadShowing = false; + if(playShowing) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(NOTIFICATION_ID_DOWNLOADING); + } else { + downloadForeground = false; + handler.post(new Runnable() { + @Override + public void run() { + downloadService.stopForeground(true); + } + }); + } + } + + public static void showSyncNotification(final Context context, int stringId, String extra) { + if(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_SYNC_NOTIFICATION, true)) { + if(extra == null) { + extra = ""; + } + + NotificationCompat.Builder builder; + builder = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.stat_notify_sync) + .setContentTitle(context.getResources().getString(stringId)) + .setContentText(extra) + .setStyle(new NotificationCompat.BigTextStyle().bigText(extra.replace(", ", "\n"))) + .setOngoing(false) + .setGroup(NOTIFICATION_SYNC_GROUP) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setAutoCancel(true); + + Intent notificationIntent = new Intent(context, SubsonicFragmentActivity.class); + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + String tab = null, type = null; + switch(stringId) { + case R.string.sync_new_albums: + type = "newest"; + break; + case R.string.sync_new_playlists: + tab = "Playlist"; + break; + case R.string.sync_new_podcasts: + tab = "Podcast"; + break; + case R.string.sync_new_starred: + type = "starred"; + break; + } + if(tab != null) { + notificationIntent.putExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE, tab); + } + if(type != null) { + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type); + } + + builder.setContentIntent(PendingIntent.getActivity(context, stringId, notificationIntent, 0)); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(stringId, builder.build()); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Pair.java b/app/src/main/java/github/daneren2005/dsub/util/Pair.java new file mode 100644 index 00000000..54386a62 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Pair.java @@ -0,0 +1,54 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.Serializable; + +/** + * @author Sindre Mehus + */ +public class Pair implements Serializable { + + private S first; + private T second; + + public Pair() { + } + + public Pair(S first, T second) { + this.first = first; + this.second = second; + } + + public S getFirst() { + return first; + } + + public void setFirst(S first) { + this.first = first; + } + + public T getSecond() { + return second; + } + + public void setSecond(T second) { + this.second = second; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java b/app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java new file mode 100644 index 00000000..c6d58f42 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java @@ -0,0 +1,27 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +/** + * @author Sindre Mehus + */ +public interface ProgressListener { + void updateProgress(String message); + void updateProgress(int messageId); +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java b/app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java new file mode 100644 index 00000000..7eb6d137 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java @@ -0,0 +1,31 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus +*/ +package github.daneren2005.dsub.util; + +import android.app.backup.BackupAgentHelper; +import android.app.backup.SharedPreferencesBackupHelper; +import github.daneren2005.dsub.util.Constants; + +public class SettingsBackupAgent extends BackupAgentHelper { + public void onCreate() { + super.onCreate(); + SharedPreferencesBackupHelper helper = new SharedPreferencesBackupHelper(this, Constants.PREFERENCES_FILE_NAME); + addHelper("mypreferences", helper); + } + } diff --git a/app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java b/app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java new file mode 100644 index 00000000..7da35f77 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java @@ -0,0 +1,212 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.FileUtil; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class ShufflePlayBuffer { + + private static final String TAG = ShufflePlayBuffer.class.getSimpleName(); + private static final String CACHE_FILENAME = "shuffleBuffer.ser"; + + private ScheduledExecutorService executorService; + private Runnable runnable; + private boolean firstRun = true; + private final ArrayList buffer = new ArrayList(); + private int lastCount = -1; + private DownloadService context; + private boolean awaitingResults = false; + private int capacity; + private int refillThreshold; + + private SharedPreferences.OnSharedPreferenceChangeListener listener; + private int currentServer; + private String currentFolder = ""; + private String genre = ""; + private String startYear = ""; + private String endYear = ""; + + public ShufflePlayBuffer(DownloadService context) { + this.context = context; + + executorService = Executors.newSingleThreadScheduledExecutor(); + runnable = new Runnable() { + @Override + public void run() { + refill(); + } + }; + executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS); + + // Calculate out the capacity and refill threshold based on the user's random size preference + int shuffleListSize = Integer.parseInt(Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_RANDOM_SIZE, "20")); + // ex: default 20 -> 50 + capacity = shuffleListSize * 5 / 2; + capacity = Math.min(500, capacity); + + // ex: default 20 -> 40 + refillThreshold = capacity * 4 / 5; + } + + public List get(int size) { + clearBufferIfnecessary(); + // Make sure fetcher is running if needed + restart(); + + List result = new ArrayList(size); + synchronized (buffer) { + boolean removed = false; + while (!buffer.isEmpty() && result.size() < size) { + result.add(buffer.remove(buffer.size() - 1)); + removed = true; + } + + // Re-cache if anything is taken out + if(removed) { + FileUtil.serialize(context, buffer, CACHE_FILENAME); + } + } + Log.i(TAG, "Taking " + result.size() + " songs from shuffle play buffer. " + buffer.size() + " remaining."); + if(result.isEmpty()) { + awaitingResults = true; + } + return result; + } + + public void shutdown() { + executorService.shutdown(); + Util.getPreferences(context).unregisterOnSharedPreferenceChangeListener(listener); + } + + private void restart() { + synchronized(buffer) { + if(buffer.size() <= refillThreshold && lastCount != 0 && executorService.isShutdown()) { + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0, 10, TimeUnit.SECONDS); + } + } + } + + private void refill() { + // Check if active server has changed. + clearBufferIfnecessary(); + + if (buffer != null && (buffer.size() > refillThreshold || (!Util.isNetworkConnected(context) && !Util.isOffline(context)) || lastCount == 0)) { + executorService.shutdown(); + return; + } + + try { + MusicService service = MusicServiceFactory.getMusicService(context); + + // Get capacity based + int n = capacity - buffer.size(); + String folder = null; + if(!Util.isTagBrowsing(context)) { + folder = Util.getSelectedMusicFolderId(context); + } + MusicDirectory songs = service.getRandomSongs(n, folder, genre, startYear, endYear, context, null); + + synchronized (buffer) { + lastCount = 0; + for(MusicDirectory.Entry entry: songs.getChildren()) { + if(!buffer.contains(entry) && entry.getRating() != 1) { + buffer.add(entry); + lastCount++; + } + } + Log.i(TAG, "Refilled shuffle play buffer with " + lastCount + " songs."); + + // Cache buffer + FileUtil.serialize(context, buffer, CACHE_FILENAME); + } + } catch (Exception x) { + // Give it one more try before quitting + if(lastCount != -2) { + lastCount = -2; + } else if(lastCount == -2) { + lastCount = 0; + } + Log.w(TAG, "Failed to refill shuffle play buffer.", x); + } + + if(awaitingResults) { + awaitingResults = false; + context.checkDownloads(); + } + } + + private void clearBufferIfnecessary() { + synchronized (buffer) { + final SharedPreferences prefs = Util.getPreferences(context); + if (currentServer != Util.getActiveServer(context) + || !Util.equals(currentFolder, Util.getSelectedMusicFolderId(context)) + || (genre != null && !genre.equals(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, ""))) + || (startYear != null && !startYear.equals(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, ""))) + || (endYear != null && !endYear.equals(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, "")))) { + lastCount = -1; + currentServer = Util.getActiveServer(context); + currentFolder = Util.getSelectedMusicFolderId(context); + genre = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, ""); + startYear = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, ""); + endYear = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, ""); + buffer.clear(); + + if(firstRun) { + ArrayList cacheList = FileUtil.deserialize(context, CACHE_FILENAME, ArrayList.class); + if(cacheList != null) { + buffer.addAll(cacheList); + } + + listener = new SharedPreferences.OnSharedPreferenceChangeListener() { + @Override + public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { + clearBufferIfnecessary(); + restart(); + } + }; + prefs.registerOnSharedPreferenceChangeListener(listener); + firstRun = false; + } else { + // Clear cache + File file = new File(context.getCacheDir(), CACHE_FILENAME); + file.delete(); + } + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java b/app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java new file mode 100644 index 00000000..b99b7e0e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java @@ -0,0 +1,48 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.content.Context; + +/** + * @author Sindre Mehus + */ +public abstract class SilentBackgroundTask extends BackgroundTask { + public SilentBackgroundTask(Context context) { + super(context); + } + + @Override + public void execute() { + queue.offer(task = new Task()); + } + + @Override + protected void done(T result) { + // Don't do anything unless overriden + } + + @Override + public void updateProgress(int messageId) { + } + + @Override + public void updateProgress(String message) { + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java b/app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java new file mode 100644 index 00000000..9c0b36a9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java @@ -0,0 +1,37 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.os.Binder; + +/** + * @author Sindre Mehus + */ +public class SimpleServiceBinder extends Binder { + + private final S service; + + public SimpleServiceBinder(S service) { + this.service = service; + } + + public S getService() { + return service; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java b/app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java new file mode 100644 index 00000000..a369715f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java @@ -0,0 +1,222 @@ +package github.daneren2005.dsub.util; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.support.v4.app.NotificationCompat; + +import java.io.File; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; + +/** + * Created by Scott on 11/24/13. + */ +public final class SyncUtil { + private static String TAG = SyncUtil.class.getSimpleName(); + private static ArrayList syncedPlaylists; + private static ArrayList syncedPodcasts; + private static String url; + + private static void checkRestURL(Context context) { + int instance = Util.getActiveServer(context); + String newURL = Util.getRestUrl(context, null, instance, false); + if(url == null || !url.equals(newURL)) { + syncedPlaylists = null; + syncedPodcasts = null; + url = newURL; + } + } + + // Playlist sync + public static boolean isSyncedPlaylist(Context context, String playlistId) { + checkRestURL(context); + if(syncedPlaylists == null) { + syncedPlaylists = getSyncedPlaylists(context); + } + return syncedPlaylists.contains(new SyncSet(playlistId)); + } + public static ArrayList getSyncedPlaylists(Context context) { + return getSyncedPlaylists(context, Util.getActiveServer(context)); + } + public static ArrayList getSyncedPlaylists(Context context, int instance) { + String syncFile = getPlaylistSyncFile(context, instance); + ArrayList playlists = FileUtil.deserializeCompressed(context, syncFile, ArrayList.class); + if(playlists == null) { + playlists = new ArrayList(); + + // Try to convert old style into new style + ArrayList oldPlaylists = FileUtil.deserialize(context, syncFile, ArrayList.class); + // If exists, time to convert! + if(oldPlaylists != null) { + for(String id: oldPlaylists) { + playlists.add(new SyncSet(id)); + } + + FileUtil.serializeCompressed(context, playlists, syncFile); + } + } + return playlists; + } + public static void setSyncedPlaylists(Context context, int instance, ArrayList playlists) { + FileUtil.serializeCompressed(context, playlists, getPlaylistSyncFile(context, instance)); + } + public static void addSyncedPlaylist(Context context, String playlistId) { + String playlistFile = getPlaylistSyncFile(context); + ArrayList playlists = getSyncedPlaylists(context); + SyncSet set = new SyncSet(playlistId); + if(!playlists.contains(set)) { + playlists.add(set); + } + FileUtil.serializeCompressed(context, playlists, playlistFile); + syncedPlaylists = playlists; + } + public static void removeSyncedPlaylist(Context context, String playlistId) { + int instance = Util.getActiveServer(context); + removeSyncedPlaylist(context, playlistId, instance); + } + public static void removeSyncedPlaylist(Context context, String playlistId, int instance) { + String playlistFile = getPlaylistSyncFile(context, instance); + ArrayList playlists = getSyncedPlaylists(context, instance); + SyncSet set = new SyncSet(playlistId); + if(playlists.contains(set)) { + playlists.remove(set); + FileUtil.serializeCompressed(context, playlists, playlistFile); + syncedPlaylists = playlists; + } + } + public static String getPlaylistSyncFile(Context context) { + int instance = Util.getActiveServer(context); + return getPlaylistSyncFile(context, instance); + } + public static String getPlaylistSyncFile(Context context, int instance) { + return "sync-playlist-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + // Podcast sync + public static boolean isSyncedPodcast(Context context, String podcastId) { + checkRestURL(context); + if(syncedPodcasts == null) { + syncedPodcasts = getSyncedPodcasts(context); + } + return syncedPodcasts.contains(new SyncSet(podcastId)); + } + public static ArrayList getSyncedPodcasts(Context context) { + return getSyncedPodcasts(context, Util.getActiveServer(context)); + } + public static ArrayList getSyncedPodcasts(Context context, int instance) { + ArrayList podcasts = FileUtil.deserialize(context, getPodcastSyncFile(context, instance), ArrayList.class); + if(podcasts == null) { + podcasts = new ArrayList(); + } + return podcasts; + } + public static void addSyncedPodcast(Context context, String podcastId, List synced) { + String podcastFile = getPodcastSyncFile(context); + ArrayList podcasts = getSyncedPodcasts(context); + SyncSet set = new SyncSet(podcastId, synced); + if(!podcasts.contains(set)) { + podcasts.add(set); + } + FileUtil.serialize(context, podcasts, podcastFile); + syncedPodcasts = podcasts; + } + public static void removeSyncedPodcast(Context context, String podcastId) { + removeSyncedPodcast(context, podcastId, Util.getActiveServer(context)); + } + public static void removeSyncedPodcast(Context context, String podcastId, int instance) { + String podcastFile = getPodcastSyncFile(context, instance); + ArrayList podcasts = getSyncedPodcasts(context, instance); + SyncSet set = new SyncSet(podcastId); + if(podcasts.contains(set)) { + podcasts.remove(set); + FileUtil.serialize(context, podcasts, podcastFile); + syncedPodcasts = podcasts; + } + } + public static String getPodcastSyncFile(Context context) { + int instance = Util.getActiveServer(context); + return getPodcastSyncFile(context, instance); + } + public static String getPodcastSyncFile(Context context, int instance) { + return "sync-podcast-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + // Starred + public static ArrayList getSyncedStarred(Context context, int instance) { + ArrayList list = FileUtil.deserializeCompressed(context, getStarredSyncFile(context, instance), ArrayList.class); + if(list == null) { + list = new ArrayList(); + } + return list; + } + public static void setSyncedStarred(ArrayList syncedList, Context context, int instance) { + FileUtil.serializeCompressed(context, syncedList, SyncUtil.getStarredSyncFile(context, instance)); + } + public static String getStarredSyncFile(Context context, int instance) { + return "sync-starred-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + // Most Recently Added + public static ArrayList getSyncedMostRecent(Context context, int instance) { + ArrayList list = FileUtil.deserialize(context, getMostRecentSyncFile(context, instance), ArrayList.class); + if(list == null) { + list = new ArrayList(); + } + return list; + } + public static void removeMostRecentSyncFiles(Context context) { + int total = Util.getServerCount(context); + for(int i = 0; i < total; i++) { + File file = new File(context.getCacheDir(), getMostRecentSyncFile(context, i)); + file.delete(); + } + } + public static String getMostRecentSyncFile(Context context, int instance) { + return "sync-most_recent-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + public static String joinNames(List names) { + StringBuilder builder = new StringBuilder(); + for (String val : names) { + builder.append(val).append(", "); + } + builder.setLength(builder.length() - 2); + return builder.toString(); + } + + public static class SyncSet implements Serializable { + public String id; + public List synced; + + protected SyncSet() { + + } + public SyncSet(String id) { + this.id = id; + } + public SyncSet(String id, List synced) { + this.id = id; + this.synced = synced; + } + + @Override + public boolean equals(Object obj) { + if(obj instanceof SyncSet) { + return this.id.equals(((SyncSet)obj).id); + } else { + return false; + } + } + + @Override + public int hashCode() { + return id.hashCode(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java b/app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java new file mode 100644 index 00000000..759e893a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java @@ -0,0 +1,51 @@ +package github.daneren2005.dsub.util; + +import github.daneren2005.dsub.fragments.SubsonicFragment; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public abstract class TabBackgroundTask extends BackgroundTask { + + private final SubsonicFragment tabFragment; + + public TabBackgroundTask(SubsonicFragment fragment) { + super(fragment.getActivity()); + tabFragment = fragment; + } + + @Override + public void execute() { + tabFragment.setProgressVisible(true); + + queue.offer(task = new Task() { + @Override + public void onDone(T result) { + tabFragment.setProgressVisible(false); + done(result); + } + + @Override + public void onError(Throwable t) { + tabFragment.setProgressVisible(false); + error(t); + } + }); + } + + @Override + public boolean isCancelled() { + return !tabFragment.isAdded() || cancelled.get(); + } + + @Override + public void updateProgress(final String message) { + getHandler().post(new Runnable() { + @Override + public void run() { + tabFragment.updateProgress(message); + } + }); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java b/app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java new file mode 100644 index 00000000..8b7df783 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java @@ -0,0 +1,55 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.lang.ref.SoftReference; +import java.util.concurrent.TimeUnit; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class TimeLimitedCache { + + private SoftReference value; + private final long ttlMillis; + private long expires; + + public TimeLimitedCache(long ttl, TimeUnit timeUnit) { + this.ttlMillis = TimeUnit.MILLISECONDS.convert(ttl, timeUnit); + } + + public T get() { + return System.currentTimeMillis() < expires ? value.get() : null; + } + + public void set(T value) { + set(value, ttlMillis, TimeUnit.MILLISECONDS); + } + + public void set(T value, long ttl, TimeUnit timeUnit) { + this.value = new SoftReference(value); + expires = System.currentTimeMillis() + timeUnit.toMillis(ttl); + } + + public void clear() { + expires = 0L; + value = null; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/UserUtil.java b/app/src/main/java/github/daneren2005/dsub/util/UserUtil.java new file mode 100644 index 00000000..29618424 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/UserUtil.java @@ -0,0 +1,452 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.fragments.SubsonicFragment; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.adapter.SettingsAdapter; + +public final class UserUtil { + private static final String TAG = UserUtil.class.getSimpleName(); + private static final long MIN_VERIFY_DURATION = 1000L * 60L * 60L; + + private static int instance = -1; + private static User currentUser; + private static long lastVerifiedTime = 0; + + + public static void refreshCurrentUser(Context context, boolean forceRefresh) { + refreshCurrentUser(context, forceRefresh, false); + } + public static void refreshCurrentUser(Context context, boolean forceRefresh, boolean unAuth) { + currentUser = null; + if(unAuth) { + lastVerifiedTime = 0; + } + seedCurrentUser(context, forceRefresh); + } + + public static void seedCurrentUser(Context context) { + seedCurrentUser(context, false); + } + public static void seedCurrentUser(final Context context, final boolean refresh) { + // Only try to seed if online + if(Util.isOffline(context)) { + currentUser = null; + return; + } + + final int instance = Util.getActiveServer(context); + if(UserUtil.instance == instance && currentUser != null) { + return; + } else { + UserUtil.instance = instance; + } + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + currentUser = MusicServiceFactory.getMusicService(context).getUser(refresh, getCurrentUsername(context, instance), context, null); + + // If running, redo cast selector + DownloadService downloadService = DownloadService.getInstance(); + if(downloadService != null) { + downloadService.userSettingsChanged(); + } + + return null; + } + + @Override + protected void done(Void result) { + if(context instanceof ActionBarActivity) { + ((ActionBarActivity) context).supportInvalidateOptionsMenu(); + } + } + + @Override + protected void error(Throwable error) { + // Don't do anything, supposed to be background pull + Log.e(TAG, "Failed to seed user information"); + } + }.execute(); + } + + public static User getCurrentUser() { + return currentUser; + } + + public static String getCurrentUsername(Context context, int instance) { + SharedPreferences prefs = Util.getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null); + } + + public static String getCurrentUsername(Context context) { + return getCurrentUsername(context, Util.getActiveServer(context)); + } + + public static boolean isCurrentAdmin() { + return isCurrentRole(User.ADMIN); + } + + public static boolean canPodcast() { + return isCurrentRole(User.PODCAST); + } + public static boolean canShare() { + return isCurrentRole(User.SHARE); + } + public static boolean canJukebox() { + return isCurrentRole(User.JUKEBOX); + } + public static boolean canScrobble() { + return isCurrentRole(User.SCROBBLING, true); + } + + public static boolean isCurrentRole(String role) { + return isCurrentRole(role, false); + } + public static boolean isCurrentRole(String role, boolean defaultValue) { + if(currentUser == null) { + return defaultValue; + } + + for(User.Setting setting: currentUser.getSettings()) { + if(setting.getName().equals(role)) { + return setting.getValue() == true; + } + } + + return defaultValue; + } + + public static void confirmCredentials(final Activity context, final Runnable onSuccess) { + final long currentTime = System.currentTimeMillis(); + // If already ran this check within last x time, just go ahead and auth + if((currentTime - lastVerifiedTime) < MIN_VERIFY_DURATION) { + onSuccess.run(); + } else { + View layout = context.getLayoutInflater().inflate(R.layout.confirm_password, null); + final TextView passwordView = (TextView) layout.findViewById(R.id.password); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.admin_confirm_password) + .setView(layout) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + String password = passwordView.getText().toString(); + + SharedPreferences prefs = Util.getPreferences(context); + String correctPassword = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + Util.getActiveServer(context), null); + + if(password != null && password.equals(correctPassword)) { + lastVerifiedTime = currentTime; + onSuccess.run(); + } else { + Util.toast(context, R.string.admin_confirm_password_bad); + } + } + }) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + } + + public static void changePassword(final Activity context, final User user) { + View layout = context.getLayoutInflater().inflate(R.layout.change_password, null); + final TextView passwordView = (TextView) layout.findViewById(R.id.new_password); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.admin_change_password) + .setView(layout) + .setPositiveButton(R.string.common_save, null) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + final AlertDialog dialog = builder.create(); + dialog.show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String password = passwordView.getText().toString(); + // Don't allow blank passwords + if ("".equals(password)) { + Util.toast(context, R.string.admin_change_password_invalid); + return; + } + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.changePassword(user.getUsername(), password, context, null); + return null; + } + + @Override + protected void done(Void v) { + Util.toast(context, context.getResources().getString(R.string.admin_change_password_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_change_password_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + + dialog.dismiss(); + } + }); + } + + public static void updateSettings(final Context context, final User user) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.updateUser(user, context, null); + user.setSettings(user.getSettings()); + return null; + } + + @Override + protected void done(Void v) { + Util.toast(context, context.getResources().getString(R.string.admin_update_permissions_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_update_permissions_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + } + + public static void changeEmail(final Activity context, final User user) { + View layout = context.getLayoutInflater().inflate(R.layout.change_email, null); + final TextView emailView = (TextView) layout.findViewById(R.id.new_email); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.admin_change_email) + .setView(layout) + .setPositiveButton(R.string.common_save, null) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + final AlertDialog dialog = builder.create(); + dialog.show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String email = emailView.getText().toString(); + // Don't allow blank emails + if ("".equals(email)) { + Util.toast(context, R.string.admin_change_email_invalid); + return; + } + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.changeEmail(user.getUsername(), email, context, null); + user.setEmail(email); + return null; + } + + @Override + protected void done(Void v) { + Util.toast(context, context.getResources().getString(R.string.admin_change_email_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_change_email_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + + dialog.dismiss(); + } + }); + } + + public static void deleteUser(final Context context, final User user, final ArrayAdapter adapter) { + Util.confirmDialog(context, R.string.common_delete, user.getUsername(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deleteUser(user.getUsername(), context, null); + return null; + } + + @Override + protected void done(Void v) { + if(adapter != null) { + adapter.remove(user); + adapter.notifyDataSetChanged(); + } + + Util.toast(context, context.getResources().getString(R.string.admin_delete_user_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_delete_user_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + } + }); + } + + public static void addNewUser(final Activity context, final SubsonicFragment fragment) { + final User user = new User(); + for(String role: User.ROLES) { + if(role.equals(User.SETTINGS) || role.equals(User.STREAM)) { + user.addSetting(role, true); + } else { + user.addSetting(role, false); + } + } + + View layout = context.getLayoutInflater().inflate(R.layout.create_user, null); + final TextView usernameView = (TextView) layout.findViewById(R.id.username); + final TextView emailView = (TextView) layout.findViewById(R.id.email); + final TextView passwordView = (TextView) layout.findViewById(R.id.password); + final ListView listView = (ListView) layout.findViewById(R.id.settings_list); + listView.setAdapter(new SettingsAdapter(context, user, true)); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.menu_add_user) + .setView(layout) + .setPositiveButton(R.string.common_save, null) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + final AlertDialog dialog = builder.create(); + dialog.show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String username = usernameView.getText().toString(); + // Don't allow blank emails + if ("".equals(username)) { + Util.toast(context, R.string.admin_change_username_invalid); + return; + } + + final String email = emailView.getText().toString(); + // Don't allow blank emails + if ("".equals(email)) { + Util.toast(context, R.string.admin_change_email_invalid); + return; + } + + final String password = passwordView.getText().toString(); + if ("".equals(password)) { + Util.toast(context, R.string.admin_change_password_invalid); + return; + } + + user.setUsername(username); + user.setEmail(email); + user.setPassword(password); + + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.createUser(user, context, null); + return null; + } + + @Override + protected void done(Void v) { + fragment.onRefresh(); + Util.toast(context, context.getResources().getString(R.string.admin_create_user_success)); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_create_user_error); + } + + Util.toast(context, msg); + } + }.execute(); + + dialog.dismiss(); + } + }); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Util.java b/app/src/main/java/github/daneren2005/dsub/util/Util.java new file mode 100644 index 00000000..75d8d5dd --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Util.java @@ -0,0 +1,1339 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.media.AudioManager.OnAudioFocusChangeListener; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Environment; +import android.text.Html; +import android.text.SpannableString; +import android.text.method.LinkMovementMethod; +import android.text.util.Linkify; +import android.util.Log; +import android.view.Gravity; +import android.widget.TextView; +import android.widget.Toast; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.RepeatMode; +import github.daneren2005.dsub.receiver.MediaButtonIntentReceiver; +import github.daneren2005.dsub.service.DownloadService; + +import org.apache.http.HttpEntity; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.security.MessageDigest; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public final class Util { + private static final String TAG = Util.class.getSimpleName(); + + private static final DecimalFormat GIGA_BYTE_FORMAT = new DecimalFormat("0.00 GB"); + private static final DecimalFormat MEGA_BYTE_FORMAT = new DecimalFormat("0.00 MB"); + private static final DecimalFormat KILO_BYTE_FORMAT = new DecimalFormat("0 KB"); + + private static DecimalFormat GIGA_BYTE_LOCALIZED_FORMAT = null; + private static DecimalFormat MEGA_BYTE_LOCALIZED_FORMAT = null; + private static DecimalFormat KILO_BYTE_LOCALIZED_FORMAT = null; + private static DecimalFormat BYTE_LOCALIZED_FORMAT = null; + private static SimpleDateFormat DATE_FORMAT_SHORT = new SimpleDateFormat("MMM d h:mm a"); + private static SimpleDateFormat DATE_FORMAT_LONG = new SimpleDateFormat("MMM d, yyyy h:mm a"); + private static int CURRENT_YEAR = new Date().getYear(); + + public static final String EVENT_META_CHANGED = "github.daneren2005.dsub.EVENT_META_CHANGED"; + public static final String EVENT_PLAYSTATE_CHANGED = "github.daneren2005.dsub.EVENT_PLAYSTATE_CHANGED"; + + public static final String AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged"; + public static final String AVRCP_METADATA_CHANGED = "com.android.music.metachanged"; + + private static OnAudioFocusChangeListener focusListener; + private static boolean pauseFocus = false; + private static boolean lowerFocus = false; + + // Used by hexEncode() + private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + + private static Toast toast; + + private Util() { + } + + public static boolean isOffline(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_OFFLINE, false); + } + + public static void setOffline(Context context, boolean offline) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_OFFLINE, offline); + editor.commit(); + } + + public static boolean isScreenLitOnDownload(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, false); + } + + public static RepeatMode getRepeatMode(Context context) { + SharedPreferences prefs = getPreferences(context); + return RepeatMode.valueOf(prefs.getString(Constants.PREFERENCES_KEY_REPEAT_MODE, RepeatMode.OFF.name())); + } + + public static void setRepeatMode(Context context, RepeatMode repeatMode) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name()); + editor.commit(); + } + + public static boolean isScrobblingEnabled(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, true) && (isOffline(context) || UserUtil.canScrobble()); + } + + public static void setActiveServer(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance); + editor.commit(); + } + + public static int getActiveServer(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_OFFLINE, false) ? 0 : prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + } + + public static int getServerCount(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getInt(Constants.PREFERENCES_KEY_SERVER_COUNT, 1); + } + + public static void removeInstanceName(Context context, int instance, int activeInstance) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + + int newInstance = instance + 1; + + // Get what the +1 server details are + String server = prefs.getString(Constants.PREFERENCES_KEY_SERVER_KEY + newInstance, null); + String serverName = prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + newInstance, null); + String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + newInstance, null); + String userName = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + newInstance, null); + String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + newInstance, null); + String musicFolderId = prefs.getString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + newInstance, null); + + // Store the +1 server details in the to be deleted instance + editor.putString(Constants.PREFERENCES_KEY_SERVER_KEY + instance, server); + editor.putString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, serverName); + editor.putString(Constants.PREFERENCES_KEY_SERVER_URL + instance, serverUrl); + editor.putString(Constants.PREFERENCES_KEY_USERNAME + instance, userName); + editor.putString(Constants.PREFERENCES_KEY_PASSWORD + instance, password); + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, musicFolderId); + + // Delete the +1 server instance + // Calling method will loop up to fill this in if +2 server exists + editor.putString(Constants.PREFERENCES_KEY_SERVER_KEY + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_SERVER_NAME + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_SERVER_URL + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_USERNAME + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_PASSWORD + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + newInstance, null); + editor.commit(); + + if (instance == activeInstance) { + if(instance != 1) { + Util.setActiveServer(context, 1); + } else { + Util.setOffline(context, true); + } + } else if (newInstance == activeInstance) { + Util.setActiveServer(context, instance); + } + } + + public static String getServerName(Context context) { + SharedPreferences prefs = getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + return prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, null); + } + public static String getServerName(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, null); + } + + public static void setSelectedMusicFolderId(Context context, String musicFolderId) { + int instance = getActiveServer(context); + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, musicFolderId); + editor.commit(); + } + + public static String getSelectedMusicFolderId(Context context) { + return getSelectedMusicFolderId(context, getActiveServer(context)); + } + public static String getSelectedMusicFolderId(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, null); + } + + public static boolean getAlbumListsPerFolder(Context context) { + return getAlbumListsPerFolder(context, getActiveServer(context)); + } + public static boolean getAlbumListsPerFolder(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_ALBUMS_PER_FOLDER + instance, false); + } + public static void setAlbumListsPerFolder(Context context, boolean perFolder) { + int instance = getActiveServer(context); + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_ALBUMS_PER_FOLDER + instance, perFolder); + editor.commit(); + } + + public static String getTheme(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_THEME, null); + } + public static void setTheme(Context context, String theme) { + SharedPreferences.Editor editor = getPreferences(context).edit(); + editor.putString(Constants.PREFERENCES_KEY_THEME, theme); + editor.commit(); + } + + public static void applyTheme(Context context, String theme) { + if ("dark".equals(theme)) { + context.setTheme(R.style.Theme_DSub_Dark); + } else if ("black".equals(theme)) { + context.setTheme(R.style.Theme_DSub_Black); + } else if ("holo".equals(theme)) { + context.setTheme(R.style.Theme_DSub_Holo); + } else { + context.setTheme(R.style.Theme_DSub_Light); + } + + SharedPreferences prefs = Util.getPreferences(context); + if(prefs.getBoolean(Constants.PREFERENCES_KEY_OVERRIDE_SYSTEM_LANGUAGE, false)) { + Configuration config = new Configuration(); + config.locale = Locale.ENGLISH; + context.getResources().updateConfiguration(config, context.getResources().getDisplayMetrics()); + } + } + + public static boolean getDisplayTrack(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_DISPLAY_TRACK, false); + } + + public static int getMaxBitrate(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null) { + return 0; + } + + boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + SharedPreferences prefs = getPreferences(context); + return Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, "0")); + } + + public static int getMaxVideoBitrate(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null) { + return 0; + } + + boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + SharedPreferences prefs = getPreferences(context); + return Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_MAX_VIDEO_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_VIDEO_BITRATE_MOBILE, "0")); + } + + public static int getPreloadCount(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null) { + return 3; + } + + SharedPreferences prefs = getPreferences(context); + boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + int preloadCount = Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_PRELOAD_COUNT_WIFI : Constants.PREFERENCES_KEY_PRELOAD_COUNT_MOBILE, "-1")); + return preloadCount == -1 ? Integer.MAX_VALUE : preloadCount; + } + + public static int getCacheSizeMB(Context context) { + SharedPreferences prefs = getPreferences(context); + int cacheSize = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_CACHE_SIZE, "-1")); + return cacheSize == -1 ? Integer.MAX_VALUE : cacheSize; + } + + public static String getRestUrl(Context context, String method) { + return getRestUrl(context, method, true); + } + public static String getRestUrl(Context context, String method, boolean allowAltAddress) { + SharedPreferences prefs = getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + return getRestUrl(context, method, prefs, instance, allowAltAddress); + } + public static String getRestUrl(Context context, String method, int instance) { + return getRestUrl(context, method, instance, true); + } + public static String getRestUrl(Context context, String method, int instance, boolean allowAltAddress) { + SharedPreferences prefs = getPreferences(context); + return getRestUrl(context, method, prefs, instance, allowAltAddress); + } + public static String getRestUrl(Context context, String method, SharedPreferences prefs, int instance) { + return getRestUrl(context, method, prefs, instance, true); + } + public static String getRestUrl(Context context, String method, SharedPreferences prefs, int instance, boolean allowAltAddress) { + StringBuilder builder = new StringBuilder(); + + String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + if(allowAltAddress && Util.isWifiConnected(context)) { + String SSID = prefs.getString(Constants.PREFERENCES_KEY_SERVER_LOCAL_NETWORK_SSID + instance, ""); + String currentSSID = Util.getSSID(context); + + String[] ssidParts = SSID.split(","); + if("".equals(SSID) || SSID.equals(currentSSID) || Arrays.asList(ssidParts).contains(currentSSID)) { + String internalUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_INTERNAL_URL + instance, null); + if(internalUrl != null && !"".equals(internalUrl) && !"http://".equals(internalUrl)) { + serverUrl = internalUrl; + } + } + } + + String username = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null); + String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null); + + // Slightly obfuscate password + password = "enc:" + Util.utf8HexEncode(password); + + builder.append(serverUrl); + if (builder.charAt(builder.length() - 1) != '/') { + builder.append("/"); + } + builder.append("rest/").append(method).append(".view"); + builder.append("?u=").append(username); + builder.append("&p=").append(password); + builder.append("&v=").append(Constants.REST_PROTOCOL_VERSION); + builder.append("&c=").append(Constants.REST_CLIENT_ID); + + return builder.toString(); + } + + public static String replaceInternalUrl(Context context, String url) { + // Only change to internal when using https + if(url.indexOf("https") != -1) { + SharedPreferences prefs = Util.getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + String internalUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_INTERNAL_URL + instance, null); + if(internalUrl != null && !"".equals(internalUrl)) { + String externalUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + url = url.replace(internalUrl, externalUrl); + } + } + + // Use separate profile for Chromecast so users can do ogg on phone, mp3 for CC + return url.replace("c=" + Constants.REST_CLIENT_ID, "c=" + Constants.CHROMECAST_CLIENT_ID); + } + + public static boolean isTagBrowsing(Context context) { + return isTagBrowsing(context, Util.getActiveServer(context)); + } + public static boolean isTagBrowsing(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_BROWSE_TAGS + instance, false); + } + + public static boolean isSyncEnabled(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SERVER_SYNC + instance, true); + } + + public static String getParentFromEntry(Context context, MusicDirectory.Entry entry) { + if(Util.isTagBrowsing(context)) { + if(!entry.isDirectory()) { + return entry.getAlbumId(); + } else if(entry.isAlbum()) { + return entry.getArtistId(); + } else { + return null; + } + } else { + return entry.getParent(); + } + } + + public static String openToTab(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_OPEN_TO_TAB, null); + } + + public static boolean disableExitPrompt(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_DISABLE_EXIT_PROMPT, false); + } + + public static String getVideoPlayerType(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_VIDEO_PLAYER, "raw"); + } + + public static SharedPreferences getPreferences(Context context) { + return context.getSharedPreferences(Constants.PREFERENCES_FILE_NAME, 0); + } + public static SharedPreferences getOfflineSync(Context context) { + return context.getSharedPreferences(Constants.OFFLINE_SYNC_NAME, 0); + } + + public static String getSyncDefault(Context context) { + SharedPreferences prefs = Util.getOfflineSync(context); + return prefs.getString(Constants.OFFLINE_SYNC_DEFAULT, null); + } + public static void setSyncDefault(Context context, String defaultValue) { + SharedPreferences.Editor editor = Util.getOfflineSync(context).edit(); + editor.putString(Constants.OFFLINE_SYNC_DEFAULT, defaultValue); + editor.commit(); + } + + public static String getCacheName(Context context, String name, String id) { + return getCacheName(context, getActiveServer(context), name, id); + } + public static String getCacheName(Context context, int instance, String name, String id) { + String s = getRestUrl(context, null, instance, false) + id; + return name + "-" + s.hashCode() + ".ser"; + } + public static String getCacheName(Context context, String name) { + return getCacheName(context, getActiveServer(context), name); + } + public static String getCacheName(Context context, int instance, String name) { + String s = getRestUrl(context, null, instance, false); + return name + "-" + s.hashCode() + ".ser"; + } + + public static int offlineScrobblesCount(Context context) { + SharedPreferences offline = getOfflineSync(context); + return offline.getInt(Constants.OFFLINE_SCROBBLE_COUNT, 0); + } + public static int offlineStarsCount(Context context) { + SharedPreferences offline = getOfflineSync(context); + return offline.getInt(Constants.OFFLINE_STAR_COUNT, 0); + } + + public static String parseOfflineIDSearch(Context context, String id, String cacheLocation) { + // Try to get this info based off of tags first + String name = parseOfflineIDSearch(id); + if(name != null) { + return name; + } + + // Otherwise go nuts trying to parse from file structure + name = id.replace(cacheLocation, ""); + if(name.startsWith("/")) { + name = name.substring(1); + } + name = name.replace(".complete", "").replace(".partial", ""); + int index = name.lastIndexOf("."); + name = index == -1 ? name : name.substring(0, index); + String[] details = name.split("/"); + + String title = details[details.length - 1]; + if(index == -1) { + if(details.length > 1) { + String artist = "artist:\"" + details[details.length - 2] + "\""; + String simpleArtist = "artist:\"" + title + "\""; + title = "album:\"" + title + "\""; + if(details[details.length - 1].equals(details[details.length - 2])) { + name = title; + } else { + name = "(" + artist + " AND " + title + ")" + " OR " + simpleArtist; + } + } else { + name = "artist:\"" + title + "\" OR album:\"" + title + "\""; + } + } else { + String artist; + if(details.length > 2) { + artist = "artist:\"" + details[details.length - 3] + "\""; + } else { + artist = "(artist:\"" + details[0] + "\" OR album:\"" + details[0] + "\")"; + } + title = "title:\"" + title.substring(title.indexOf('-') + 1) + "\""; + name = artist + " AND " + title; + } + + return name; + } + + public static String parseOfflineIDSearch(String id) { + MusicDirectory.Entry entry = new MusicDirectory.Entry(); + File file = new File(id); + + if(file.exists()) { + entry.loadMetadata(file); + + if(entry.getArtist() != null) { + String title = file.getName(); + title = title.replace(".complete", "").replace(".partial", ""); + int index = title.lastIndexOf("."); + title = index == -1 ? title : title.substring(0, index); + title = title.substring(title.indexOf('-') + 1); + + String query = "artist:\"" + entry.getArtist() + "\"" + + " AND title:\"" + title + "\""; + + return query; + } else { + return null; + } + } else { + return null; + } + } + + public static String getContentType(HttpEntity entity) { + if (entity == null || entity.getContentType() == null) { + return null; + } + return entity.getContentType().getValue(); + } + + public static int getRemainingTrialDays(Context context) { + SharedPreferences prefs = getPreferences(context); + long installTime = prefs.getLong(Constants.PREFERENCES_KEY_INSTALL_TIME, 0L); + + if (installTime == 0L) { + installTime = System.currentTimeMillis(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(Constants.PREFERENCES_KEY_INSTALL_TIME, installTime); + editor.commit(); + } + + long now = System.currentTimeMillis(); + long millisPerDay = 24L * 60L * 60L * 1000L; + int daysSinceInstall = (int) ((now - installTime) / millisPerDay); + return Math.max(0, Constants.FREE_TRIAL_DAYS - daysSinceInstall); + } + + public static boolean isCastProxy(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_CAST_PROXY, false); + } + + public static boolean isFirstLevelArtist(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), true); + } + public static void toggleFirstLevelArtist(Context context) { + SharedPreferences prefs = Util.getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + + if(prefs.getBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), true)) { + editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), false); + } else { + editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), true); + } + + editor.commit(); + } + + public static boolean shouldStartOnHeadphones(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_START_ON_HEADPHONES, false); + } + + /** + * Get the contents of an InputStream as a byte[]. + *

+ * This method buffers the input internally, so there is no need to use a + * BufferedInputStream. + * + * @param input the InputStream to read from + * @return the requested byte array + * @throws NullPointerException if the input is null + * @throws IOException if an I/O error occurs + */ + public static byte[] toByteArray(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + copy(input, output); + return output.toByteArray(); + } + + public static long copy(InputStream input, OutputStream output) + throws IOException { + byte[] buffer = new byte[1024 * 4]; + long count = 0; + int n; + while (-1 != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + return count; + } + + public static void renameFile(File from, File to) throws IOException { + if(!from.renameTo(to)) { + Log.i(TAG, "Failed to rename " + from + " to " + to); + } + } + + public static void close(Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (Throwable x) { + // Ignored + } + } + + public static boolean delete(File file) { + if (file != null && file.exists()) { + if (!file.delete()) { + Log.w(TAG, "Failed to delete file " + file); + return false; + } + Log.i(TAG, "Deleted file " + file); + } + return true; + } + + public static void toast(Context context, int messageId) { + toast(context, messageId, true); + } + + public static void toast(Context context, int messageId, boolean shortDuration) { + toast(context, context.getString(messageId), shortDuration); + } + + public static void toast(Context context, String message) { + toast(context, message, true); + } + + public static void toast(Context context, String message, boolean shortDuration) { + if (toast == null) { + toast = Toast.makeText(context, message, shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); + toast.setGravity(Gravity.CENTER, 0, 0); + } else { + toast.setText(message); + toast.setDuration(shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); + } + toast.show(); + } + + public static void confirmDialog(Context context, int action, int subject, DialogInterface.OnClickListener onClick) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), context.getResources().getString(subject), onClick, null); + } + public static void confirmDialog(Context context, int action, int subject, DialogInterface.OnClickListener onClick, DialogInterface.OnClickListener onCancel) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), context.getResources().getString(subject), onClick, onCancel); + } + public static void confirmDialog(Context context, int action, String subject, DialogInterface.OnClickListener onClick) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), subject, onClick, null); + } + public static void confirmDialog(Context context, int action, String subject, DialogInterface.OnClickListener onClick, DialogInterface.OnClickListener onCancel) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), subject, onClick, onCancel); + } + public static void confirmDialog(Context context, String action, String subject, DialogInterface.OnClickListener onClick, DialogInterface.OnClickListener onCancel) { + new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle(R.string.common_confirm) + .setMessage(context.getResources().getString(R.string.common_confirm_message, action, subject)) + .setPositiveButton(R.string.common_ok, onClick) + .setNegativeButton(R.string.common_cancel, onCancel) + .show(); + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + *

    + *
  • format(918) returns "918 B".
  • + *
  • format(98765) returns "96 KB".
  • + *
  • format(1238476) returns "1.2 MB".
  • + *
+ * This method assumes that 1 KB is 1024 bytes. + * To get a localized string, please use formatLocalizedBytes instead. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + public static synchronized String formatBytes(long byteCount) { + + // More than 1 GB? + if (byteCount >= 1024 * 1024 * 1024) { + NumberFormat gigaByteFormat = GIGA_BYTE_FORMAT; + return gigaByteFormat.format((double) byteCount / (1024 * 1024 * 1024)); + } + + // More than 1 MB? + if (byteCount >= 1024 * 1024) { + NumberFormat megaByteFormat = MEGA_BYTE_FORMAT; + return megaByteFormat.format((double) byteCount / (1024 * 1024)); + } + + // More than 1 KB? + if (byteCount >= 1024) { + NumberFormat kiloByteFormat = KILO_BYTE_FORMAT; + return kiloByteFormat.format((double) byteCount / 1024); + } + + return byteCount + " B"; + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + *
    + *
  • format(918) returns "918 B".
  • + *
  • format(98765) returns "96 KB".
  • + *
  • format(1238476) returns "1.2 MB".
  • + *
+ * This method assumes that 1 KB is 1024 bytes. + * This version of the method returns a localized string. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + public static synchronized String formatLocalizedBytes(long byteCount, Context context) { + + // More than 1 GB? + if (byteCount >= 1024 * 1024 * 1024) { + if (GIGA_BYTE_LOCALIZED_FORMAT == null) { + GIGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_gigabyte)); + } + + return GIGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024 * 1024)); + } + + // More than 1 MB? + if (byteCount >= 1024 * 1024) { + if (MEGA_BYTE_LOCALIZED_FORMAT == null) { + MEGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_megabyte)); + } + + return MEGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024)); + } + + // More than 1 KB? + if (byteCount >= 1024) { + if (KILO_BYTE_LOCALIZED_FORMAT == null) { + KILO_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_kilobyte)); + } + + return KILO_BYTE_LOCALIZED_FORMAT.format((double) byteCount / 1024); + } + + if (BYTE_LOCALIZED_FORMAT == null) { + BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_byte)); + } + + return BYTE_LOCALIZED_FORMAT.format((double) byteCount); + } + + public static String formatDuration(Integer seconds) { + if (seconds == null) { + return null; + } + + int hours = seconds / 3600; + int minutes = (seconds / 60) % 60; + int secs = seconds % 60; + + StringBuilder builder = new StringBuilder(7); + if(hours > 0) { + builder.append(hours).append(":"); + if(minutes < 10) { + builder.append("0"); + } + } + builder.append(minutes).append(":"); + if (secs < 10) { + builder.append("0"); + } + builder.append(secs); + return builder.toString(); + } + + public static String formatDate(Date date) { + if(date == null) { + return "Never"; + } else { + if(date.getYear() != CURRENT_YEAR) { + return DATE_FORMAT_LONG.format(date); + } else { + return DATE_FORMAT_SHORT.format(date); + } + } + } + + public static boolean equals(Object object1, Object object2) { + if (object1 == object2) { + return true; + } + if (object1 == null || object2 == null) { + return false; + } + return object1.equals(object2); + + } + + /** + * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes. + * + * @param s The string to encode. + * @return The encoded string. + */ + public static String utf8HexEncode(String s) { + if (s == null) { + return null; + } + byte[] utf8; + try { + utf8 = s.getBytes(Constants.UTF_8); + } catch (UnsupportedEncodingException x) { + throw new RuntimeException(x); + } + return hexEncode(utf8); + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data Bytes to convert to hexadecimal characters. + * @return A string containing hexadecimal characters. + */ + public static String hexEncode(byte[] data) { + int length = data.length; + char[] out = new char[length << 1]; + // two characters form the hex value. + for (int i = 0, j = 0; i < length; i++) { + out[j++] = HEX_DIGITS[(0xF0 & data[i]) >>> 4]; + out[j++] = HEX_DIGITS[0x0F & data[i]]; + } + return new String(out); + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param s Data to digest. + * @return MD5 digest as a hex string. + */ + public static String md5Hex(String s) { + if (s == null) { + return null; + } + + try { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + return hexEncode(md5.digest(s.getBytes(Constants.UTF_8))); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + + public static boolean isNullOrWhiteSpace(String string) { + return string == null || "".equals(string) || "".equals(string.trim()); + } + + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + + // Calculate ratios of height and width to requested height and + // width + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will + // guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + return inSampleSize; + } + + public static int getScaledHeight(double height, double width, int newWidth) { + // Try to keep correct aspect ratio of the original image, do not force a square + double aspectRatio = height / width; + + // Assume the size given refers to the width of the image, so calculate the new height using + // the previously determined aspect ratio + return (int) Math.round(newWidth * aspectRatio); + } + + public static int getScaledHeight(Bitmap bitmap, int width) { + return Util.getScaledHeight((double) bitmap.getHeight(), (double) bitmap.getWidth(), width); + } + + public static int getStringDistance(CharSequence s, CharSequence t) { + if (s == null || t == null) { + throw new IllegalArgumentException("Strings must not be null"); + } + + if(t.toString().toLowerCase().indexOf(s.toString().toLowerCase()) != -1) { + return 1; + } + + int n = s.length(); + int m = t.length(); + + if (n == 0) { + return m; + } else if (m == 0) { + return n; + } + + if (n > m) { + final CharSequence tmp = s; + s = t; + t = tmp; + n = m; + m = t.length(); + } + + int p[] = new int[n + 1]; + int d[] = new int[n + 1]; + int _d[]; + + int i; + int j; + char t_j; + int cost; + + for (i = 0; i <= n; i++) { + p[i] = i; + } + + for (j = 1; j <= m; j++) { + t_j = t.charAt(j - 1); + d[0] = j; + + for (i = 1; i <= n; i++) { + cost = s.charAt(i - 1) == t_j ? 0 : 1; + d[i] = Math.min(Math.min(d[i - 1] + 1, p[i] + 1), p[i - 1] + cost); + } + + _d = p; + p = d; + d = _d; + } + + return p[n]; + } + + public static boolean isNetworkConnected(Context context) { + return isNetworkConnected(context, false); + } + public static boolean isNetworkConnected(Context context, boolean streaming) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + boolean connected = networkInfo != null && networkInfo.isConnected(); + + if(streaming) { + boolean wifiConnected = connected && networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + boolean wifiRequired = isWifiRequiredForDownload(context); + + return connected && (!wifiRequired || wifiConnected); + } else { + return connected; + } + } + public static boolean isWifiConnected(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + boolean connected = networkInfo != null && networkInfo.isConnected(); + return connected && (networkInfo.getType() == ConnectivityManager.TYPE_WIFI); + } + public static String getSSID(Context context) { + if (isWifiConnected(context)) { + WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + if (wifiManager.getConnectionInfo() != null && wifiManager.getConnectionInfo().getSSID() != null) { + return wifiManager.getConnectionInfo().getSSID().replace("\"", ""); + } + return null; + } + return null; + } + + public static boolean isExternalStoragePresent() { + return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); + } + + private static boolean isWifiRequiredForDownload(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, false); + } + + public static void info(Context context, int titleId, int messageId) { + info(context, titleId, messageId, true); + } + public static void info(Context context, int titleId, String message) { + info(context, titleId, message, true); + } + public static void info(Context context, String title, String message) { + info(context, title, message, true); + } + public static void info(Context context, int titleId, int messageId, boolean linkify) { + showDialog(context, android.R.drawable.ic_dialog_info, titleId, messageId, linkify); + } + public static void info(Context context, int titleId, String message, boolean linkify) { + showDialog(context, android.R.drawable.ic_dialog_info, titleId, message, linkify); + } + public static void info(Context context, String title, String message, boolean linkify) { + showDialog(context, android.R.drawable.ic_dialog_info, title, message, linkify); + } + + private static void showDialog(Context context, int icon, int titleId, int messageId) { + showDialog(context, icon, titleId, messageId, true); + } + private static void showDialog(Context context, int icon, int titleId, String message) { + showDialog(context, icon, titleId, message, true); + } + private static void showDialog(Context context, int icon, String title, String message) { + showDialog(context, icon, title, message, true); + } + private static void showDialog(Context context, int icon, int titleId, int messageId, boolean linkify) { + showDialog(context, icon, context.getResources().getString(titleId), context.getResources().getString(messageId), linkify); + } + private static void showDialog(Context context, int icon, int titleId, String message, boolean linkify) { + showDialog(context, icon, context.getResources().getString(titleId), message, linkify); + } + private static void showDialog(Context context, int icon, String title, String message, boolean linkify) { + SpannableString ss = new SpannableString(message); + if(linkify) { + Linkify.addLinks(ss, Linkify.ALL); + } + + AlertDialog dialog = new AlertDialog.Builder(context) + .setIcon(icon) + .setTitle(title) + .setMessage(ss) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }) + .show(); + + ((TextView)dialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + } + public static void showHTMLDialog(Context context, int title, int message) { + showHTMLDialog(context, title, context.getResources().getString(message)); + } + public static void showHTMLDialog(Context context, int title, String message) { + AlertDialog dialog = new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_info) + .setTitle(title) + .setMessage(Html.fromHtml(message)) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }) + .show(); + + ((TextView)dialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + } + + public static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException x) { + Log.w(TAG, "Interrupted from sleep.", x); + } + } + + public static void startActivityWithoutTransition(Activity currentActivity, Class newActivitiy) { + startActivityWithoutTransition(currentActivity, new Intent(currentActivity, newActivitiy)); + } + + public static void startActivityWithoutTransition(Activity currentActivity, Intent intent) { + currentActivity.startActivity(intent); + disablePendingTransition(currentActivity); + } + + public static void disablePendingTransition(Activity activity) { + + // Activity.overridePendingTransition() was introduced in Android 2.0. Use reflection to maintain + // compatibility with 1.5. + try { + Method method = Activity.class.getMethod("overridePendingTransition", int.class, int.class); + method.invoke(activity, 0, 0); + } catch (Throwable x) { + // Ignored + } + } + + public static Drawable createDrawableFromBitmap(Context context, Bitmap bitmap) { + // BitmapDrawable(Resources, Bitmap) was introduced in Android 1.6. Use reflection to maintain + // compatibility with 1.5. + try { + Constructor constructor = BitmapDrawable.class.getConstructor(Resources.class, Bitmap.class); + return constructor.newInstance(context.getResources(), bitmap); + } catch (Throwable x) { + return new BitmapDrawable(bitmap); + } + } + + public static int getAttribute(Context context, int attr) { + int res; + int[] attrs = new int[] {attr}; + TypedArray typedArray = context.obtainStyledAttributes(attrs); + res = typedArray.getResourceId(0, 0); + typedArray.recycle(); + return res; + } + + public static void registerMediaButtonEventReceiver(Context context) { + + // Only do it if enabled in the settings. + SharedPreferences prefs = getPreferences(context); + boolean enabled = prefs.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true); + + if (enabled) { + + // AudioManager.registerMediaButtonEventReceiver() was introduced in Android 2.2. + // Use reflection to maintain compatibility with 1.5. + try { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName()); + Method method = AudioManager.class.getMethod("registerMediaButtonEventReceiver", ComponentName.class); + method.invoke(audioManager, componentName); + } catch (Throwable x) { + // Ignored. + } + } + } + + public static void unregisterMediaButtonEventReceiver(Context context) { + // AudioManager.unregisterMediaButtonEventReceiver() was introduced in Android 2.2. + // Use reflection to maintain compatibility with 1.5. + try { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName()); + Method method = AudioManager.class.getMethod("unregisterMediaButtonEventReceiver", ComponentName.class); + method.invoke(audioManager, componentName); + } catch (Throwable x) { + // Ignored. + } + } + + @TargetApi(8) + public static void requestAudioFocus(final Context context) { + if (Build.VERSION.SDK_INT >= 8 && focusListener == null) { + final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + audioManager.requestAudioFocus(focusListener = new OnAudioFocusChangeListener() { + public void onAudioFocusChange(int focusChange) { + DownloadService downloadService = (DownloadService)context; + if((focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) && !downloadService.isRemoteEnabled()) { + if(downloadService.getPlayerState() == PlayerState.STARTED) { + Log.i(TAG, "Temporary loss of focus"); + SharedPreferences prefs = getPreferences(context); + int lossPref = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_TEMP_LOSS, "1")); + if(lossPref == 2 || (lossPref == 1 && focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)) { + lowerFocus = true; + downloadService.setVolume(0.1f); + } else if(lossPref == 0 || (lossPref == 1 && focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)) { + pauseFocus = true; + downloadService.pause(true); + } + } + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + if(pauseFocus) { + pauseFocus = false; + downloadService.start(); + } else if(lowerFocus) { + lowerFocus = false; + downloadService.setVolume(1.0f); + } + } else if(focusChange == AudioManager.AUDIOFOCUS_LOSS && !downloadService.isRemoteEnabled()) { + Log.i(TAG, "Permanently lost focus"); + focusListener = null; + downloadService.pause(); + audioManager.abandonAudioFocus(this); + } + } + }, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + } + + public static void abandonAudioFocus(Context context) { + if(focusListener != null) { + final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + audioManager.abandonAudioFocus(focusListener); + focusListener = null; + } + } + + /** + *

Broadcasts the given song info as the new song being played.

+ */ + public static void broadcastNewTrackInfo(Context context, MusicDirectory.Entry song) { + DownloadService downloadService = (DownloadService)context; + Intent intent = new Intent(EVENT_META_CHANGED); + Intent avrcpIntent = new Intent(AVRCP_METADATA_CHANGED); + + if (song != null) { + intent.putExtra("title", song.getTitle()); + intent.putExtra("artist", song.getArtist()); + intent.putExtra("album", song.getAlbum()); + + File albumArtFile = FileUtil.getAlbumArtFile(context, song); + intent.putExtra("coverart", albumArtFile.getAbsolutePath()); + avrcpIntent.putExtra("playing", true); + } else { + intent.putExtra("title", ""); + intent.putExtra("artist", ""); + intent.putExtra("album", ""); + intent.putExtra("coverart", ""); + avrcpIntent.putExtra("playing", false); + } + addTrackInfo(context, song, avrcpIntent); + + context.sendBroadcast(intent); + context.sendBroadcast(avrcpIntent); + } + + /** + *

Broadcasts the given player state as the one being set.

+ */ + public static void broadcastPlaybackStatusChange(Context context, MusicDirectory.Entry song, PlayerState state) { + Intent intent = new Intent(EVENT_PLAYSTATE_CHANGED); + Intent avrcpIntent = new Intent(AVRCP_PLAYSTATE_CHANGED); + + switch (state) { + case STARTED: + intent.putExtra("state", "play"); + avrcpIntent.putExtra("playing", true); + break; + case STOPPED: + intent.putExtra("state", "stop"); + avrcpIntent.putExtra("playing", false); + break; + case PAUSED: + intent.putExtra("state", "pause"); + avrcpIntent.putExtra("playing", false); + break; + case PREPARED: + // Only send quick pause event for samsung devices, causes issues for others + if(Build.MANUFACTURER.toLowerCase().indexOf("samsung") != -1) { + avrcpIntent.putExtra("playing", false); + } else { + return; // Don't broadcast anything + } + break; + case COMPLETED: + intent.putExtra("state", "complete"); + avrcpIntent.putExtra("playing", false); + break; + default: + return; // No need to broadcast. + } + addTrackInfo(context, song, avrcpIntent); + + if(state != PlayerState.PREPARED) { + context.sendBroadcast(intent); + } + context.sendBroadcast(avrcpIntent); + } + + private static void addTrackInfo(Context context, MusicDirectory.Entry song, Intent intent) { + if (song != null) { + DownloadService downloadService = (DownloadService)context; + File albumArtFile = FileUtil.getAlbumArtFile(context, song); + + intent.putExtra("track", song.getTitle()); + intent.putExtra("artist", song.getArtist()); + intent.putExtra("album", song.getAlbum()); + intent.putExtra("ListSize", (long) downloadService.getSongs().size()); + intent.putExtra("id", (long) downloadService.getCurrentPlayingIndex() + 1); + intent.putExtra("duration", (long) downloadService.getPlayerDuration()); + intent.putExtra("position", (long) downloadService.getPlayerPosition()); + intent.putExtra("coverart", albumArtFile.getAbsolutePath()); + } else { + intent.putExtra("track", ""); + intent.putExtra("artist", ""); + intent.putExtra("album", ""); + intent.putExtra("ListSize", (long) 0); + intent.putExtra("id", (long) 0); + intent.putExtra("duration", (long) 0); + intent.putExtra("position", (long) 0); + intent.putExtra("coverart", ""); + } + } + + public static WifiManager.WifiLock createWifiLock(Context context, String tag) { + WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + int lockType = WifiManager.WIFI_MODE_FULL; + if (Build.VERSION.SDK_INT >= 12) { + lockType = 3; + } + return wm.createWifiLock(lockType, tag); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java b/app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java new file mode 100644 index 00000000..ab64bca9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java @@ -0,0 +1,57 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util.compat; + +import android.support.v7.media.MediaRouter; + +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.CastMediaControlIntent; + +import github.daneren2005.dsub.service.ChromeCastController; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.RemoteController; + +/** + * Created by owner on 2/9/14. + */ +public final class CastCompat { + public static final String APPLICATION_ID = "5F85EBEB"; + + static { + try { + Class.forName("com.google.android.gms.cast.CastDevice"); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + public static void checkAvailable() throws Throwable { + // Calling here forces class initialization. + } + + public static RemoteController getController(DownloadService downloadService, MediaRouter.RouteInfo info) { + CastDevice device = CastDevice.getFromBundle(info.getExtras()); + if(device != null) { + return new ChromeCastController(downloadService, device); + } else { + return null; + } + } + + public static String getCastControlCategory() { + return CastMediaControlIntent.categoryForCast(APPLICATION_ID); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java new file mode 100644 index 00000000..320092e9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java @@ -0,0 +1,43 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory.Entry; +import android.content.ComponentName; +import android.content.Context; +import android.support.v7.media.MediaRouter; +import android.util.Log; + +public class RemoteControlClientBase extends RemoteControlClientHelper { + + private static final String TAG = RemoteControlClientBase.class.getSimpleName(); + + @Override + public void register(Context context, ComponentName mediaButtonReceiverComponent) { + + } + + @Override + public void unregister(Context context) { + + } + + @Override + public void setPlaybackState(int state) { + + } + + @Override + public void updateMetadata(Context context, Entry currentSong) { + + } + + @Override + public void registerRoute(MediaRouter router) { + + } + + @Override + public void unregisterRoute(MediaRouter router) { + + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java new file mode 100644 index 00000000..93075a28 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java @@ -0,0 +1,32 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory; +import android.content.ComponentName; +import android.content.Context; +import android.support.v7.media.MediaRouter; +import android.os.Build; + +public abstract class RemoteControlClientHelper { + + public static RemoteControlClientHelper createInstance() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return new RemoteControlClientBase(); + } else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + return new RemoteControlClientJB(); + } else { + return new RemoteControlClientICS(); + } + } + + protected RemoteControlClientHelper() { + // Avoid instantiation + } + + public abstract void register(final Context context, final ComponentName mediaButtonReceiverComponent); + public abstract void unregister(final Context context); + public abstract void setPlaybackState(final int state); + public abstract void updateMetadata(final Context context, final MusicDirectory.Entry currentSong); + public abstract void registerRoute(MediaRouter router); + public abstract void unregisterRoute(MediaRouter router); + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java new file mode 100644 index 00000000..50283da6 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java @@ -0,0 +1,104 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.ImageLoader; +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.MediaMetadataRetriever; +import android.media.RemoteControlClient; +import android.support.v7.media.MediaRouter; + +import github.daneren2005.dsub.activity.SubsonicActivity; + +@TargetApi(14) +public class RemoteControlClientICS extends RemoteControlClientHelper { + private static String TAG = RemoteControlClientICS.class.getSimpleName(); + + protected RemoteControlClient mRemoteControl; + protected ImageLoader imageLoader; + protected DownloadService downloadService; + + public void register(final Context context, final ComponentName mediaButtonReceiverComponent) { + downloadService = (DownloadService) context; + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + + // build the PendingIntent for the remote control client + Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + mediaButtonIntent.setComponent(mediaButtonReceiverComponent); + PendingIntent mediaPendingIntent = PendingIntent.getBroadcast(context.getApplicationContext(), 0, mediaButtonIntent, 0); + + // create and register the remote control client + mRemoteControl = new RemoteControlClient(mediaPendingIntent); + audioManager.registerRemoteControlClient(mRemoteControl); + + mRemoteControl.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED); + mRemoteControl.setTransportControlFlags(getTransportFlags()); + imageLoader = SubsonicActivity.getStaticImageLoader(context); + } + + public void unregister(final Context context) { + if (mRemoteControl != null) { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + audioManager.unregisterRemoteControlClient(mRemoteControl); + } + } + + public void setPlaybackState(final int state) { + mRemoteControl.setPlaybackState(state); + } + + public void updateMetadata(final Context context, final MusicDirectory.Entry currentSong) { + if(imageLoader == null) { + imageLoader = SubsonicActivity.getStaticImageLoader(context); + } + + // Update the remote controls + RemoteControlClient.MetadataEditor editor = mRemoteControl.editMetadata(true); + updateMetadata(currentSong, editor); + editor.apply(); + if (currentSong == null || imageLoader == null) { + mRemoteControl.editMetadata(true) + .putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, null) + .apply(); + } else { + imageLoader.loadImage(context, mRemoteControl, currentSong); + } + } + + @Override + public void registerRoute(MediaRouter router) { + router.addRemoteControlClient(mRemoteControl); + } + + @Override + public void unregisterRoute(MediaRouter router) { + router.removeRemoteControlClient(mRemoteControl); + } + + protected void updateMetadata(final MusicDirectory.Entry currentSong, final RemoteControlClient.MetadataEditor editor) { + editor.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, (currentSong == null) ? null : currentSong.getArtist()) + .putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, (currentSong == null) ? null : currentSong.getAlbum()) + .putString(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, (currentSong == null) ? null : currentSong.getArtist()) + .putString(MediaMetadataRetriever.METADATA_KEY_TITLE, (currentSong) == null ? null : currentSong.getTitle()) + .putString(MediaMetadataRetriever.METADATA_KEY_GENRE, (currentSong) == null ? null : currentSong.getGenre()) + .putLong(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER, (currentSong == null) ? + 0 : ((currentSong.getTrack() == null) ? 0 : currentSong.getTrack())) + .putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, (currentSong == null) ? + 0 : ((currentSong.getDuration() == null) ? 0 : (currentSong.getDuration() * 1000))); + } + + protected int getTransportFlags() { + return RemoteControlClient.FLAG_KEY_MEDIA_PLAY | + RemoteControlClient.FLAG_KEY_MEDIA_PAUSE | + RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE | + RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS | + RemoteControlClient.FLAG_KEY_MEDIA_NEXT | + RemoteControlClient.FLAG_KEY_MEDIA_STOP; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java new file mode 100644 index 00000000..c27df2ba --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java @@ -0,0 +1,58 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ImageLoader; +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.MediaMetadataRetriever; +import android.media.RemoteControlClient; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.SilentBackgroundTask; + +@TargetApi(18) +public class RemoteControlClientJB extends RemoteControlClientICS { + @Override + public void register(final Context context, final ComponentName mediaButtonReceiverComponent) { + super.register(context, mediaButtonReceiverComponent); + + mRemoteControl.setOnGetPlaybackPositionListener(new RemoteControlClient.OnGetPlaybackPositionListener() { + @Override + public long onGetPlaybackPosition() { + return downloadService.getPlayerPosition(); + } + }); + mRemoteControl.setPlaybackPositionUpdateListener(new RemoteControlClient.OnPlaybackPositionUpdateListener() { + @Override + public void onPlaybackPositionUpdate(final long newPosition) { + new SilentBackgroundTask(context) { + @Override + protected Void doInBackground() throws Throwable { + downloadService.seekTo((int) newPosition); + return null; + } + }.execute(); + setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING); + } + }); + } + + @Override + public void setPlaybackState(final int state) { + long position = -1; + if(state == RemoteControlClient.PLAYSTATE_PLAYING || state == RemoteControlClient.PLAYSTATE_PAUSED) { + position = downloadService.getPlayerPosition(); + } + mRemoteControl.setPlaybackState(state, position, 1.0f); + } + + @Override + protected int getTransportFlags() { + return super.getTransportFlags() | RemoteControlClient.FLAG_KEY_MEDIA_POSITION_UPDATE; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java b/app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java new file mode 100644 index 00000000..aa0a2e25 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + + +package github.daneren2005.dsub.util.tags; + +import java.io.RandomAccessFile; +import java.io.IOException; +import java.util.HashMap; + + +public class Bastp { + + public Bastp() { + } + + public HashMap getTags(String fname) { + HashMap tags = new HashMap(); + try { + RandomAccessFile ra = new RandomAccessFile(fname, "r"); + tags = getTags(ra); + ra.close(); + } + catch(Exception e) { + /* we dont' care much: SOMETHING went wrong. d'oh! */ + } + + return tags; + } + + public HashMap getTags(RandomAccessFile s) { + HashMap tags = new HashMap(); + byte[] file_ff = new byte[4]; + + try { + s.read(file_ff); + String magic = new String(file_ff); + if(magic.equals("fLaC")) { + tags = (new FlacFile()).getTags(s); + } + else if(magic.equals("OggS")) { + tags = (new OggFile()).getTags(s); + } + else if(file_ff[0] == -1 && file_ff[1] == -5) { /* aka 0xfffb in real languages */ + tags = (new LameHeader()).getTags(s); + } + else if(magic.substring(0,3).equals("ID3")) { + tags = (new ID3v2File()).getTags(s); + if(tags.containsKey("_hdrlen")) { + Long hlen = Long.parseLong( tags.get("_hdrlen").toString(), 10 ); + HashMap lameInfo = (new LameHeader()).parseLameHeader(s, hlen); + /* add gain tags if not already present */ + inheritTag("REPLAYGAIN_TRACK_GAIN", lameInfo, tags); + inheritTag("REPLAYGAIN_ALBUM_GAIN", lameInfo, tags); + } + } + tags.put("_magic", magic); + } + catch (IOException e) { + } + return tags; + } + + private void inheritTag(String key, HashMap from, HashMap to) { + if(!to.containsKey(key) && from.containsKey(key)) { + to.put(key, from.get(key)); + } + } + +} + diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java b/app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java new file mode 100644 index 00000000..7ff517fd --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package github.daneren2005.dsub.util.tags; + +import android.support.v4.util.LruCache; +import java.util.HashMap; +import java.util.Vector; + +public final class BastpUtil { + private static final RGLruCache rgCache = new RGLruCache(16); + + /** Returns the ReplayGain values of 'path' as + */ + public static float[] getReplayGainValues(String path) { + float[] cached = rgCache.get(path); + + if(cached == null) { + cached = getReplayGainValuesFromFile(path); + rgCache.put(path, cached); + } + return cached; + } + + + + /** Parse given file and return track,album replay gain values + */ + private static float[] getReplayGainValuesFromFile(String path) { + String[] keys = { "REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN" }; + float[] adjust= { 0f , 0f }; + HashMap tags = (new Bastp()).getTags(path); + + for (int i=0; i { + public RGLruCache(int size) { + super(size); + } + } + +} + diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/Common.java b/app/src/main/java/github/daneren2005/dsub/util/tags/Common.java new file mode 100644 index 00000000..51344d90 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/Common.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; +import java.util.Vector; + +public class Common { + private static final long MAX_PKT_SIZE = 524288; + + public void xdie(String reason) throws IOException { + throw new IOException(reason); + } + + /* + ** Returns a 32bit int from given byte offset in LE + */ + public int b2le32(byte[] b, int off) { + int r = 0; + for(int i=0; i<4; i++) { + r |= ( b2u(b[off+i]) << (8*i) ); + } + return r; + } + + public int b2be32(byte[] b, int off) { + return swap32(b2le32(b, off)); + } + + public int swap32(int i) { + return((i&0xff)<<24)+((i&0xff00)<<8)+((i&0xff0000)>>8)+((i>>24)&0xff); + } + + /* + ** convert 'byte' value into unsigned int + */ + public int b2u(byte x) { + return (x & 0xFF); + } + + /* + ** Printout debug message to STDOUT + */ + public void debug(String s) { + System.out.println("DBUG "+s); + } + + public HashMap parse_vorbis_comment(RandomAccessFile s, long offset, long payload_len) throws IOException { + HashMap tags = new HashMap(); + int comments = 0; // number of found comments + int xoff = 0; // offset within 'scratch' + int can_read = (int)(payload_len > MAX_PKT_SIZE ? MAX_PKT_SIZE : payload_len); + byte[] scratch = new byte[can_read]; + + // seek to given position and slurp in the payload + s.seek(offset); + s.read(scratch); + + // skip vendor string in format: [LEN][VENDOR_STRING] + xoff += 4 + b2le32(scratch, xoff); // 4 = LEN = 32bit int + comments = b2le32(scratch, xoff); + xoff += 4; + + // debug("comments count = "+comments); + for(int i=0; i scratch.length) + xdie("string out of bounds"); + + String tag_raw = new String(scratch, xoff-clen, clen); + String[] tag_vec = tag_raw.split("=",2); + String tag_key = tag_vec[0].toUpperCase(); + + addTagEntry(tags, tag_key, tag_vec[1]); + } + return tags; + } + + public void addTagEntry(HashMap tags, String key, String value) { + if(tags.containsKey(key)) { + ((Vector)tags.get(key)).add(value); // just add to existing vector + } + else { + Vector vx = new Vector(); + vx.add(value); + tags.put(key, vx); + } + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java b/app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java new file mode 100644 index 00000000..de3584d1 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; +import java.util.Enumeration; + + +public class FlacFile extends Common { + private static final int FLAC_TYPE_COMMENT = 4; // ID of 'VorbisComment's + + public FlacFile() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + int xoff = 4; // skip file magic + int retry = 64; + int r[]; + HashMap tags = new HashMap(); + + for(; retry > 0; retry--) { + r = parse_metadata_block(s, xoff); + + if(r[2] == FLAC_TYPE_COMMENT) { + tags = parse_vorbis_comment(s, xoff+r[0], r[1]); + break; + } + + if(r[3] != 0) + break; // eof reached + + // else: calculate next offset + xoff += r[0] + r[1]; + } + return tags; + } + + /* Parses the metadata block at 'offset' and returns + ** [header_size, payload_size, type, stop_after] + */ + private int[] parse_metadata_block(RandomAccessFile s, long offset) throws IOException { + int[] result = new int[4]; + byte[] mb_head = new byte[4]; + int stop_after = 0; + int block_type = 0; + int block_size = 0; + + s.seek(offset); + + if( s.read(mb_head) != 4 ) + xdie("failed to read metadata block header"); + + block_size = b2be32(mb_head,0); // read whole header as 32 big endian + block_type = (block_size >> 24) & 127; // BIT 1-7 are the type + stop_after = (((block_size >> 24) & 128) > 0 ? 1 : 0 ); // BIT 0 indicates the last-block flag + block_size = (block_size & 0x00FFFFFF); // byte 1-7 are the size + + // debug("size="+block_size+", type="+block_type+", is_last="+stop_after); + + result[0] = 4; // hardcoded - only returned to be consistent with OGG parser + result[1] = block_size; + result[2] = block_type; + result[3] = stop_after; + + return result; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java b/app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java new file mode 100644 index 00000000..ea61f36c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; + + +public class ID3v2File extends Common { + private static int ID3_ENC_LATIN = 0x00; + private static int ID3_ENC_UTF16LE = 0x01; + private static int ID3_ENC_UTF16BE = 0x02; + private static int ID3_ENC_UTF8 = 0x03; + + public ID3v2File() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + HashMap tags = new HashMap(); + + final int v2hdr_len = 10; + byte[] v2hdr = new byte[v2hdr_len]; + + // read the whole 10 byte header into memory + s.seek(0); + s.read(v2hdr); + + int id3v = ((b2be32(v2hdr,0))) & 0xFF; // swapped ID3\04 -> ver. ist the first byte + int v3len = ((b2be32(v2hdr,6))); // total size EXCLUDING the this 10 byte header + v3len = ((v3len & 0x7f000000) >> 3) | // for some funky reason, this is encoded as 7*4 bits + ((v3len & 0x007f0000) >> 2) | + ((v3len & 0x00007f00) >> 1) | + ((v3len & 0x0000007f) >> 0) ; + + // debug(">> tag version ID3v2."+id3v); + // debug(">> LEN= "+v3len+" // "+v3len); + + // we should already be at the first frame + // so we can start the parsing right now + tags = parse_v3_frames(s, v3len); + tags.put("_hdrlen", v3len+v2hdr_len); + return tags; + } + + /* Parses all ID3v2 frames at the current position up until payload_len + ** bytes were read + */ + public HashMap parse_v3_frames(RandomAccessFile s, long payload_len) throws IOException { + HashMap tags = new HashMap(); + byte[] frame = new byte[10]; // a frame header is always 10 bytes + long bread = 0; // total amount of read bytes + + while(bread < payload_len) { + bread += s.read(frame); + String framename = new String(frame, 0, 4); + int slen = b2be32(frame, 4); + + /* Abort on silly sizes */ + if(slen < 1 || slen > 524288) + break; + + byte[] xpl = new byte[slen]; + bread += s.read(xpl); + + if(framename.substring(0,1).equals("T")) { + String[] nmzInfo = normalizeTaginfo(framename, xpl); + + for(int i = 0; i < nmzInfo.length; i += 2) { + String oggKey = nmzInfo[i]; + String decPld = nmzInfo[i + 1]; + + if (oggKey.length() > 0 && !tags.containsKey(oggKey)) { + addTagEntry(tags, oggKey, decPld); + } + } + } + else if(framename.equals("RVA2")) { + // + } + + } + return tags; + } + + /* Converts ID3v2 sillyframes to OggNames */ + private String[] normalizeTaginfo(String k, byte[] v) { + String[] rv = new String[] {"",""}; + HashMap lu = new HashMap(); + lu.put("TIT2", "TITLE"); + lu.put("TALB", "ALBUM"); + lu.put("TPE1", "ARTIST"); + + if(lu.containsKey(k)) { + /* A normal, known key: translate into Ogg-Frame name */ + rv[0] = (String)lu.get(k); + rv[1] = getDecodedString(v); + } + else if(k.equals("TXXX")) { + /* A freestyle field, ieks! */ + String txData[] = getDecodedString(v).split(Character.toString('\0'), 2); + /* Check if we got replaygain info in key\0value style */ + if(txData.length == 2) { + if(txData[0].matches("^(?i)REPLAYGAIN_(ALBUM|TRACK)_GAIN$")) { + rv[0] = txData[0].toUpperCase(); /* some tagwriters use lowercase for this */ + rv[1] = txData[1]; + } else { + // Check for replaygain tags just thrown randomly in field + int nextStartIndex = 1; + int startName = txData[1].toLowerCase(Locale.US).indexOf("replaygain_"); + ArrayList parts = new ArrayList(); + while(startName != -1) { + int endName = txData[1].indexOf((char) 0, startName); + if(endName != -1) { + parts.add(txData[1].substring(startName, endName).toUpperCase()); + int endValue = txData[1].indexOf((char) 0, endName + 1); + if(endValue != -1) { + parts.add(txData[1].substring(endName + 1, endValue)); + nextStartIndex = endValue + 1; + } + } + + startName = txData[1].toLowerCase(Locale.US).indexOf("replaygain_", nextStartIndex); + } + + if(parts.size() > 0) { + rv = new String[parts.size()]; + rv = parts.toArray(rv); + } + } + } + } + + return rv; + } + + /* Converts a raw byte-stream text into a java String */ + private String getDecodedString(byte[] raw) { + int encid = raw[0] & 0xFF; + int len = raw.length; + String v = ""; + try { + if(encid == ID3_ENC_LATIN) { + v = new String(raw, 1, len-1, "ISO-8859-1"); + } + else if (encid == ID3_ENC_UTF8) { + v = new String(raw, 1, len-1, "UTF-8"); + } + else if (encid == ID3_ENC_UTF16LE) { + v = new String(raw, 3, len-3, "UTF-16LE"); + } + else if (encid == ID3_ENC_UTF16BE) { + v = new String(raw, 3, len-3, "UTF-16BE"); + } + } catch(Exception e) {} + return v; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java b/app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java new file mode 100644 index 00000000..720ee87f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; +import java.util.Enumeration; + + +public class LameHeader extends Common { + + public LameHeader() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + return parseLameHeader(s, 0); + } + + public HashMap parseLameHeader(RandomAccessFile s, long offset) throws IOException { + HashMap tags = new HashMap(); + byte[] chunk = new byte[4]; + + s.seek(offset + 0x24); + s.read(chunk); + + String lameMark = new String(chunk, 0, chunk.length, "ISO-8859-1"); + + if(lameMark.equals("Info") || lameMark.equals("Xing")) { + s.seek(offset+0xAB); + s.read(chunk); + + int raw = b2be32(chunk, 0); + int gtrk_raw = raw >> 16; /* first 16 bits are the raw track gain value */ + int galb_raw = raw & 0xFFFF; /* the rest is for the album gain value */ + + float gtrk_val = (float)(gtrk_raw & 0x01FF)/10; + float galb_val = (float)(galb_raw & 0x01FF)/10; + + gtrk_val = ((gtrk_raw&0x0200)!=0 ? -1*gtrk_val : gtrk_val); + galb_val = ((galb_raw&0x0200)!=0 ? -1*galb_val : galb_val); + + if( (gtrk_raw&0xE000) == 0x2000 ) { + addTagEntry(tags, "REPLAYGAIN_TRACK_GAIN", gtrk_val+" dB"); + } + if( (gtrk_raw&0xE000) == 0x4000 ) { + addTagEntry(tags, "REPLAYGAIN_ALBUM_GAIN", galb_val+" dB"); + } + + } + + return tags; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java b/app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java new file mode 100644 index 00000000..d0b31671 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2013 Adrian Ulrich + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package github.daneren2005.dsub.util.tags; + + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; + + +public class OggFile extends Common { + + private static final int OGG_PAGE_SIZE = 27; // Static size of an OGG Page + private static final int OGG_TYPE_COMMENT = 3; // ID of 'VorbisComment's + + public OggFile() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + long offset = 0; + int retry = 64; + HashMap tags = new HashMap(); + + for( ; retry > 0 ; retry-- ) { + long res[] = parse_ogg_page(s, offset); + if(res[2] == OGG_TYPE_COMMENT) { + tags = parse_ogg_vorbis_comment(s, offset+res[0], res[1]); + break; + } + offset += res[0] + res[1]; + } + return tags; + } + + + /* Parses the ogg page at offset 'offset' and returns + ** [header_size, payload_size, type] + */ + private long[] parse_ogg_page(RandomAccessFile s, long offset) throws IOException { + long[] result = new long[3]; // [header_size, payload_size] + byte[] p_header = new byte[OGG_PAGE_SIZE]; // buffer for the page header + byte[] scratch; + int bread = 0; // number of bytes read + int psize = 0; // payload-size + int nsegs = 0; // Number of segments + + s.seek(offset); + bread = s.read(p_header); + if(bread != OGG_PAGE_SIZE) + xdie("Unable to read() OGG_PAGE_HEADER"); + if((new String(p_header, 0, 5)).equals("OggS\0") != true) + xdie("Invalid magic - not an ogg file?"); + + nsegs = b2u(p_header[26]); + // debug("> file seg: "+nsegs); + if(nsegs > 0) { + scratch = new byte[nsegs]; + bread = s.read(scratch); + if(bread != nsegs) + xdie("Failed to read segtable"); + + for(int i=0; i pre-read */ + if(psize >= 1 && s.read(p_header, 0, 1) == 1) { + result[2] = b2u(p_header[0]); + } + + return result; + } + + /* In 'vorbiscomment' field is prefixed with \3vorbis in OGG files + ** we check that this marker is present and call the generic comment + ** parset with the correct offset (+7) */ + private HashMap parse_ogg_vorbis_comment(RandomAccessFile s, long offset, long pl_len) throws IOException { + final int pfx_len = 7; + byte[] pfx = new byte[pfx_len]; + + if(pl_len < pfx_len) + xdie("ogg vorbis comment field is too short!"); + + s.seek(offset); + s.read(pfx); + + if( (new String(pfx, 0, pfx_len)).equals("\3vorbis") == false ) + xdie("Damaged packet found!"); + + return parse_vorbis_comment(s, offset+pfx_len, pl_len-pfx_len); + } + +}; diff --git a/app/src/main/java/github/daneren2005/dsub/view/AlbumCell.java b/app/src/main/java/github/daneren2005/dsub/view/AlbumCell.java new file mode 100644 index 00000000..8707ece7 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/AlbumCell.java @@ -0,0 +1,108 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.RatingBar; +import android.widget.TextView; + +import java.io.File; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ImageLoader; + +public class AlbumCell extends UpdateView { + private static final String TAG = AlbumCell.class.getSimpleName(); + + private Context context; + private MusicDirectory.Entry album; + private File file; + + private View coverArtView; + private TextView titleView; + private TextView artistView; + private boolean showArtist = true; + + public AlbumCell(Context context) { + super(context); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.album_cell_item, this, true); + + coverArtView = findViewById(R.id.album_coverart); + titleView = (TextView) findViewById(R.id.album_title); + artistView = (TextView) findViewById(R.id.album_artist); + + ratingBar = (RatingBar) findViewById(R.id.album_rating); + ratingBar.setFocusable(false); + starButton = (ImageButton) findViewById(R.id.album_star); + starButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.album_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + public void setShowArtist(boolean showArtist) { + this.showArtist = showArtist; + } + + protected void setObjectImpl(Object obj1, Object obj2) { + this.album = (MusicDirectory.Entry) obj1; + titleView.setText(album.getAlbumDisplay()); + String artist = ""; + if(showArtist) { + artist = album.getArtist(); + if (artist == null) { + artist = ""; + } + if (album.getYear() != null) { + artist += " - " + album.getYear(); + } + } else if(album.getYear() != null) { + artist += album.getYear(); + } + artistView.setText(album.getArtist() == null ? "" : artist); + imageTask = ((ImageLoader)obj2).loadImage(coverArtView, album, false, true); + file = null; + } + + @Override + protected void updateBackground() { + if(file == null) { + file = FileUtil.getAlbumDirectory(context, album); + } + + exists = file.exists(); + isStarred = album.isStarred(); + isRated = album.getRating(); + } + + public MusicDirectory.Entry getEntry() { + return album; + } + + public File getFile() { + return file; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/AlbumView.java b/app/src/main/java/github/daneren2005/dsub/view/AlbumView.java new file mode 100644 index 00000000..bd54ea1e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/AlbumView.java @@ -0,0 +1,107 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.RatingBar; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.Util; +import java.io.File; +import java.util.List; + +/** + * Used to display albums in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class AlbumView extends UpdateView { + private static final String TAG = AlbumView.class.getSimpleName(); + + private Context context; + private MusicDirectory.Entry album; + private File file; + + private TextView titleView; + private TextView artistView; + private View coverArtView; + + public AlbumView(Context context) { + super(context); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.album_list_item, this, true); + + titleView = (TextView) findViewById(R.id.album_title); + artistView = (TextView) findViewById(R.id.album_artist); + coverArtView = findViewById(R.id.album_coverart); + ratingBar = (RatingBar) findViewById(R.id.album_rating); + starButton = (ImageButton) findViewById(R.id.album_star); + starButton.setFocusable(false); + + moreButton = (ImageView) findViewById(R.id.album_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + protected void setObjectImpl(Object obj1, Object obj2) { + this.album = (MusicDirectory.Entry) obj1; + titleView.setText(album.getAlbumDisplay()); + String artist = album.getArtist(); + if(artist == null) { + artist = ""; + } + if(album.getYear() != null) { + artist += " - " + album.getYear(); + } + artistView.setText(artist); + artistView.setVisibility(album.getArtist() == null ? View.GONE : View.VISIBLE); + imageTask = ((ImageLoader)obj2).loadImage(coverArtView, album, false, true); + file = null; + } + + @Override + protected void updateBackground() { + if(file == null) { + file = FileUtil.getAlbumDirectory(context, album); + } + + exists = file.exists(); + isStarred = album.isStarred(); + isRated = album.getRating(); + } + + public MusicDirectory.Entry getEntry() { + return album; + } + + public File getFile() { + return file; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/ArtistEntryView.java b/app/src/main/java/github/daneren2005/dsub/view/ArtistEntryView.java new file mode 100644 index 00000000..71bdeb78 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/ArtistEntryView.java @@ -0,0 +1,79 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.util.Util; +import java.io.File; +/** + * Used to display albums in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class ArtistEntryView extends UpdateView { + private static final String TAG = ArtistEntryView.class.getSimpleName(); + + private Context context; + private MusicDirectory.Entry artist; + private File file; + + private TextView titleView; + + public ArtistEntryView(Context context) { + super(context); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.basic_list_item, this, true); + + titleView = (TextView) findViewById(R.id.item_name); + starButton = (ImageButton) findViewById(R.id.item_star); + starButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.item_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + protected void setObjectImpl(Object obj) { + this.artist = (MusicDirectory.Entry) obj; + titleView.setText(artist.getTitle()); + file = FileUtil.getArtistDirectory(context, artist); + } + + @Override + protected void updateBackground() { + exists = file.exists(); + isStarred = artist.isStarred(); + } + + public File getFile() { + return file; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/ArtistView.java b/app/src/main/java/github/daneren2005/dsub/view/ArtistView.java new file mode 100644 index 00000000..c255be69 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/ArtistView.java @@ -0,0 +1,78 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.util.FileUtil; + +import java.io.File; + +/** + * Used to display albums in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class ArtistView extends UpdateView { + private static final String TAG = ArtistView.class.getSimpleName(); + + private Context context; + private Artist artist; + private File file; + + private TextView titleView; + + public ArtistView(Context context) { + super(context); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.basic_list_item, this, true); + + titleView = (TextView) findViewById(R.id.item_name); + starButton = (ImageButton) findViewById(R.id.item_star); + starButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.item_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + protected void setObjectImpl(Object obj) { + this.artist = (Artist) obj; + titleView.setText(artist.getName()); + file = FileUtil.getArtistDirectory(context, artist); + } + + @Override + protected void updateBackground() { + exists = file.exists(); + isStarred = artist.isStarred(); + } + + public File getFile() { + return file; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/AutoRepeatButton.java b/app/src/main/java/github/daneren2005/dsub/view/AutoRepeatButton.java new file mode 100644 index 00000000..3c59dd37 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/AutoRepeatButton.java @@ -0,0 +1,86 @@ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageButton; + +public class AutoRepeatButton extends ImageButton { + + private static final long initialRepeatDelay = 1000; + private static final long repeatIntervalInMilliseconds = 300; + private boolean doClick = true; + private Runnable repeatEvent = null; + + private Runnable repeatClickWhileButtonHeldRunnable = new Runnable() { + @Override + public void run() { + doClick = false; + //Perform the present repetition of the click action provided by the user + // in setOnClickListener(). + if(repeatEvent != null) + repeatEvent.run(); + + //Schedule the next repetitions of the click action, using a faster repeat + // interval than the initial repeat delay interval. + postDelayed(repeatClickWhileButtonHeldRunnable, repeatIntervalInMilliseconds); + } + }; + + private void commonConstructorCode() { + this.setOnTouchListener(new OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + int action = event.getAction(); + if(action == MotionEvent.ACTION_DOWN) + { + doClick = true; + //Just to be sure that we removed all callbacks, + // which should have occurred in the ACTION_UP + removeCallbacks(repeatClickWhileButtonHeldRunnable); + + //Schedule the start of repetitions after a one half second delay. + postDelayed(repeatClickWhileButtonHeldRunnable, initialRepeatDelay); + + setPressed(true); + } + else if(action == MotionEvent.ACTION_UP) { + //Cancel any repetition in progress. + removeCallbacks(repeatClickWhileButtonHeldRunnable); + + if(doClick || repeatEvent == null) { + performClick(); + } + + setPressed(false); + } + + //Returning true here prevents performClick() from getting called + // in the usual manner, which would be redundant, given that we are + // already calling it above. + return true; + } + }); + } + + public void setOnRepeatListener(Runnable runnable) { + repeatEvent = runnable; + } + + public AutoRepeatButton(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + commonConstructorCode(); + } + + + public AutoRepeatButton(Context context, AttributeSet attrs) { + super(context, attrs); + commonConstructorCode(); + } + + public AutoRepeatButton(Context context) { + super(context); + commonConstructorCode(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/ChangeLog.java b/app/src/main/java/github/daneren2005/dsub/view/ChangeLog.java new file mode 100644 index 00000000..096583c7 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/ChangeLog.java @@ -0,0 +1,546 @@ +/* + * Copyright (C) 2012 Christian Ketterer (cketti) + * + * Portions Copyright (C) 2012 Martin van Zuilekom (http://martin.cubeactive.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + * Based on android-change-log: + * + * Copyright (C) 2011, Karsten Priegnitz + * + * Permission to use, copy, modify, and distribute this piece of software + * for any purpose with or without fee is hereby granted, provided that + * the above copyright notice and this permission notice appear in the + * source code of all copies. + * + * It would be appreciated if you mention the author in your change log, + * contributors list or the like. + * + * http://code.google.com/p/android-change-log/ + */ +package github.daneren2005.dsub.view; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import android.preference.PreferenceManager; +import android.util.Log; +import android.util.SparseArray; +import android.webkit.WebView; +import github.daneren2005.dsub.R; + + +/** + * Display a dialog showing a full or partial (What's New) change log. + */ +public class ChangeLog { + /** + * Tag that is used when sending error/debug messages to the log. + */ + protected static final String LOG_TAG = "ckChangeLog"; + + /** + * This is the key used when storing the version code in SharedPreferences. + */ + protected static final String VERSION_KEY = "ckChangeLog_last_version_code"; + + /** + * Constant that used when no version code is available. + */ + protected static final int NO_VERSION = -1; + + /** + * Default CSS styles used to format the change log. + */ + private static final String DEFAULT_CSS = + "div.title { margin-left: 0px; font-size: 1.2em; text-align: center;}" + + "div.subtitle {margin-left: 0px; font-size: .8em; text-align: center;}" + + "li { margin-left: 0px;}" + + "ul { padding-left: 2em;}"; + + + /** + * Context that is used to access the resources and to create the ChangeLog dialogs. + */ + protected final Context mContext; + + /** + * Contains the CSS rules used to format the change log. + */ + protected final String mCss; + + /** + * Last version code read from {@code SharedPreferences} or {@link #NO_VERSION}. + */ + private int mLastVersionCode; + + /** + * Version code of the current installation. + */ + private int mCurrentVersionCode; + + /** + * Version name of the current installation. + */ + private String mCurrentVersionName; + + + /** + * Contains constants for the root element of {@code changelog.xml}. + */ + protected interface ChangeLogTag { + static final String NAME = "changelog"; + } + + /** + * Contains constants for the release element of {@code changelog.xml}. + */ + protected interface ReleaseTag { + static final String NAME = "release"; + static final String ATTRIBUTE_VERSION = "version"; + static final String ATTRIBUTE_VERSION_CODE = "versioncode"; + static final String ATTRIBUTE_RELEASE_DATE = "releasedate"; + } + + /** + * Contains constants for the change element of {@code changelog.xml}. + */ + protected interface ChangeTag { + static final String NAME = "change"; + } + + /** + * Create a {@code ChangeLog} instance using the default {@link SharedPreferences} file. + * + * @param context + * Context that is used to access the resources and to create the ChangeLog dialogs. + */ + public ChangeLog(Context context) { + this(context, PreferenceManager.getDefaultSharedPreferences(context), DEFAULT_CSS); + } + + /** + * Create a {@code ChangeLog} instance using the default {@link SharedPreferences} file. + * + * @param context + * Context that is used to access the resources and to create the ChangeLog dialogs. + * @param css + * CSS styles that will be used to format the change log. + */ + public ChangeLog(Context context, String css) { + this(context, PreferenceManager.getDefaultSharedPreferences(context), css); + } + + public ChangeLog(Context context, SharedPreferences preferences) { + this(context, preferences, DEFAULT_CSS); + } + + /** + * Create a {@code ChangeLog} instance using the supplied {@code SharedPreferences} instance. + * + * @param context + * Context that is used to access the resources and to create the ChangeLog dialogs. + * @param preferences + * {@code SharedPreferences} instance that is used to persist the last version code. + * @param css + * CSS styles used to format the change log (excluding {@code }). + * + */ + public ChangeLog(Context context, SharedPreferences preferences, String css) { + mContext = context; + mCss = css; + + // Get last version code + mLastVersionCode = preferences.getInt(VERSION_KEY, NO_VERSION); + + // Get current version code and version name + try { + PackageInfo packageInfo = context.getPackageManager().getPackageInfo( + context.getPackageName(), 0); + + mCurrentVersionCode = packageInfo.versionCode; + mCurrentVersionName = packageInfo.versionName; + } catch (NameNotFoundException e) { + mCurrentVersionCode = NO_VERSION; + Log.e(LOG_TAG, "Could not get version information from manifest!", e); + } + } + + /** + * Get version code of last installation. + * + * @return The version code of the last installation of this app (as described in the former + * manifest). This will be the same as returned by {@link #getCurrentVersionCode()} the + * second time this version of the app is launched (more precisely: the second time + * {@code ChangeLog} is instantiated). + * + * @see AndroidManifest.xml#android:versionCode + */ + public int getLastVersionCode() { + return mLastVersionCode; + } + + /** + * Get version code of current installation. + * + * @return The version code of this app as described in the manifest. + * + * @see AndroidManifest.xml#android:versionCode + */ + public int getCurrentVersionCode() { + return mCurrentVersionCode; + } + + /** + * Get version name of current installation. + * + * @return The version name of this app as described in the manifest. + * + * @see AndroidManifest.xml#android:versionName + */ + public String getCurrentVersionName() { + return mCurrentVersionName; + } + + /** + * Check if this is the first execution of this app version. + * + * @return {@code true} if this version of your app is started the first time. + */ + public boolean isFirstRun() { + return mLastVersionCode < mCurrentVersionCode; + } + + /** + * Check if this is a new installation. + * + * @return {@code true} if your app including {@code ChangeLog} is started the first time ever. + * Also {@code true} if your app was uninstalled and installed again. + */ + public boolean isFirstRunEver() { + return mLastVersionCode == NO_VERSION; + } + + /** + * Get the "What's New" dialog. + * + * @return An AlertDialog displaying the changes since the previous installed version of your + * app (What's New). But when this is the first run of your app including + * {@code ChangeLog} then the full log dialog is show. + */ + public AlertDialog getLogDialog() { + return getDialog(isFirstRunEver()); + } + + /** + * Get a dialog with the full change log. + * + * @return An AlertDialog with a full change log displayed. + */ + public AlertDialog getFullLogDialog() { + return getDialog(true); + } + + /** + * Create a dialog containing (parts of the) change log. + * + * @param full + * If this is {@code true} the full change log is displayed. Otherwise only changes for + * versions newer than the last version are displayed. + * + * @return A dialog containing the (partial) change log. + */ + protected AlertDialog getDialog(boolean full) { + WebView wv = new WebView(mContext); + //wv.setBackgroundColor(0); // transparent + String log = getLog(full); + // No changes to show + if(log == null) { + return null; + } + + wv.loadDataWithBaseURL(null, log, "text/html", "UTF-8", null); + + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + builder.setTitle( + mContext.getResources().getString( + full ? R.string.changelog_full_title : R.string.changelog_title)) + .setView(wv) + .setCancelable(false) + // OK button + .setPositiveButton( + mContext.getResources().getString(R.string.changelog_ok_button), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // The user clicked "OK" so save the current version code as + // "last version code". + updateVersionInPreferences(); + } + }); + + if (!full) { + // Show "More…" button if we're only displaying a partial change log. + builder.setNegativeButton(R.string.changelog_show_full, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + getFullLogDialog().show(); + } + }); + } + + return builder.create(); + } + + /** + * Write current version code to the preferences. + */ + public void updateVersionInPreferences() { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(mContext); + SharedPreferences.Editor editor = sp.edit(); + editor.putInt(VERSION_KEY, mCurrentVersionCode); + + // TODO: Update preferences from a background thread + editor.commit(); + } + + /** + * Get changes since last version as HTML string. + * + * @return HTML string containing the changes since the previous installed version of your app + * (What's New). + */ + public String getLog() { + return getLog(false); + } + + /** + * Get full change log as HTML string. + * + * @return HTML string containing the full change log. + */ + public String getFullLog() { + return getLog(true); + } + + /** + * Get (partial) change log as HTML string. + * + * @param full + * If this is {@code true} the full change log is returned. Otherwise only changes for + * versions newer than the last version are returned. + * + * @return The (partial) change log. + */ + private String getLog(boolean full) { + StringBuilder sb = new StringBuilder(); + + sb.append(""); + + Resources resources = mContext.getResources(); + + // Read master change log from xml/changelog.xml + SparseArray changelog; + XmlResourceParser resXml = mContext.getResources().getXml(R.xml.changelog); + try { + changelog = readChangeLog(resXml, full); + } finally { + resXml.close(); + } + + String versionFormat = resources.getString(R.string.changelog_version_format); + + // Get all version codes from the master change log... + List versions = new ArrayList(changelog.size()); + for (int i = 0, len = changelog.size(); i < len; i++) { + int key = changelog.keyAt(i); + versions.add(key); + } + + // ... and sort them (newest version first). + Collections.sort(versions, Collections.reverseOrder()); + + if(versions.size() == 0) { + return null; + } + + for (Integer version : versions) { + int key = version.intValue(); + + // Use release information from localized change log and fall back to the master file + // if necessary. + ReleaseItem release = changelog.get(key); + + sb.append("
"); + sb.append(String.format(versionFormat, release.versionName)); + sb.append("
"); + if(release.releaseDate != null) { + sb.append("
"); + sb.append(release.releaseDate); + sb.append("
"); + } + sb.append("
    "); + for (String change : release.changes) { + sb.append("
  • "); + sb.append(change); + sb.append("
  • "); + } + sb.append("
"); + } + + sb.append(""); + + return sb.toString(); + } + + /** + * Read the change log from an XML file. + * + * @param xml + * The {@code XmlPullParser} instance used to read the change log. + * @param full + * If {@code true} the full change log is read. Otherwise only the changes since the + * last (saved) version are read. + * + * @return A {@code SparseArray} mapping the version codes to release information. + */ + protected SparseArray readChangeLog(XmlPullParser xml, boolean full) { + SparseArray result = new SparseArray(); + + try { + int eventType = xml.getEventType(); + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && xml.getName().equals(ReleaseTag.NAME)) { + if (parseReleaseTag(xml, full, result)) { + // Stop reading more elements if this entry is not newer than the last + // version. + break; + } + } + eventType = xml.next(); + } + } catch (XmlPullParserException e) { + Log.e(LOG_TAG, e.getMessage(), e); + } catch (IOException e) { + Log.e(LOG_TAG, e.getMessage(), e); + } + + return result; + } + + /** + * Parse the {@code release} tag of a change log XML file. + * + * @param xml + * The {@code XmlPullParser} instance used to read the change log. + * @param full + * If {@code true} the contents of the {@code release} tag are always added to + * {@code changelog}. Otherwise only if the item's {@code versioncode} attribute is + * higher than the last version code. + * @param changelog + * The {@code SparseArray} to add a new {@link ReleaseItem} instance to. + * + * @return {@code true} if the {@code release} element is describing changes of a version older + * or equal to the last version. In that case {@code changelog} won't be modified and + * {@link #readChangeLog(XmlPullParser, boolean)} will stop reading more elements from + * the change log file. + * + * @throws XmlPullParserException + * @throws IOException + */ + private boolean parseReleaseTag(XmlPullParser xml, boolean full, + SparseArray changelog) throws XmlPullParserException, IOException { + + String version = xml.getAttributeValue(null, ReleaseTag.ATTRIBUTE_VERSION); + + int versionCode; + try { + String versionCodeStr = xml.getAttributeValue(null, ReleaseTag.ATTRIBUTE_VERSION_CODE); + versionCode = Integer.parseInt(versionCodeStr); + } catch (NumberFormatException e) { + versionCode = NO_VERSION; + } + + String releaseDate = xml.getAttributeValue(null, ReleaseTag.ATTRIBUTE_RELEASE_DATE); + + if (!full && versionCode <= mLastVersionCode) { + return true; + } + + int eventType = xml.getEventType(); + List changes = new ArrayList(); + while (eventType != XmlPullParser.END_TAG || xml.getName().equals(ChangeTag.NAME)) { + if (eventType == XmlPullParser.START_TAG && xml.getName().equals(ChangeTag.NAME)) { + eventType = xml.next(); + + changes.add(xml.getText()); + } + eventType = xml.next(); + } + + ReleaseItem release = new ReleaseItem(versionCode, version, releaseDate, changes); + changelog.put(versionCode, release); + + return false; + } + + /** + * Container used to store information about a release/version. + */ + protected static class ReleaseItem { + /** + * Version code of the release. + */ + public final int versionCode; + + /** + * Version name of the release. + */ + public final String versionName; + + public final String releaseDate; + + /** + * List of changes introduced with that release. + */ + public final List changes; + + ReleaseItem(int versionCode, String versionName, String releaseDate, List changes) { + this.versionCode = versionCode; + this.versionName = versionName; + this.releaseDate = releaseDate; + this.changes = changes; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/ErrorDialog.java b/app/src/main/java/github/daneren2005/dsub/view/ErrorDialog.java new file mode 100644 index 00000000..0b9d05a0 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/ErrorDialog.java @@ -0,0 +1,75 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; +import github.daneren2005.dsub.util.Util; + +/** + * @author Sindre Mehus + */ +public class ErrorDialog { + + public ErrorDialog(Activity activity, int messageId, boolean finishActivityOnCancel) { + this(activity, activity.getResources().getString(messageId), finishActivityOnCancel); + } + + public ErrorDialog(final Activity activity, String message, final boolean finishActivityOnClose) { + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setTitle(R.string.error_label); + builder.setMessage(message); + builder.setCancelable(true); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + if (finishActivityOnClose) { + restart(activity); + } + } + }); + builder.setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + if (finishActivityOnClose) { + restart(activity); + } + } + }); + + try { + builder.create().show(); + } catch(Exception e) { + // Don't care, just means no activity to attach to + } + } + + private void restart(Activity activity) { + Intent intent = new Intent(activity, SubsonicFragmentActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + Util.startActivityWithoutTransition(activity, intent); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/FadeOutAnimation.java b/app/src/main/java/github/daneren2005/dsub/view/FadeOutAnimation.java new file mode 100644 index 00000000..817839ef --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/FadeOutAnimation.java @@ -0,0 +1,77 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; + +/** + * Fades a view out by changing its alpha value. + * + * @author Sindre Mehus + * @version $Id: Util.java 3203 2012-10-04 09:12:08Z sindre_mehus $ + */ +public class FadeOutAnimation extends AlphaAnimation { + + private boolean cancelled; + + /** + * Creates and starts the fade out animation. + * + * @param view The view to fade out (or display). + * @param fadeOut If true, the view is faded out. Otherwise it is immediately made visible. + * @param durationMillis Fade duration. + */ + public static void createAndStart(View view, boolean fadeOut, long durationMillis) { + if (fadeOut) { + view.clearAnimation(); + view.startAnimation(new FadeOutAnimation(view, durationMillis)); + } else { + Animation animation = view.getAnimation(); + if (animation instanceof FadeOutAnimation) { + ((FadeOutAnimation) animation).cancelFadeOut(); + } + view.clearAnimation(); + view.setVisibility(View.VISIBLE); + } + } + + FadeOutAnimation(final View view, long durationMillis) { + super(1.0F, 0.0F); + setDuration(durationMillis); + setAnimationListener(new AnimationListener() { + public void onAnimationStart(Animation animation) { + } + + public void onAnimationRepeat(Animation animation) { + } + + public void onAnimationEnd(Animation animation) { + if (!cancelled) { + view.setVisibility(View.INVISIBLE); + } + } + }); + } + + private void cancelFadeOut() { + cancelled = true; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/GenreView.java b/app/src/main/java/github/daneren2005/dsub/view/GenreView.java new file mode 100644 index 00000000..8dbcf89d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/GenreView.java @@ -0,0 +1,58 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Genre; + +public class GenreView extends UpdateView { + private static final String TAG = GenreView.class.getSimpleName(); + + private TextView titleView; + private TextView songsView; + private TextView albumsView; + + public GenreView(Context context) { + super(context, false); + LayoutInflater.from(context).inflate(R.layout.genre_list_item, this, true); + + titleView = (TextView) findViewById(R.id.genre_name); + songsView = (TextView) findViewById(R.id.genre_songs); + albumsView = (TextView) findViewById(R.id.genre_albums); + } + + public void setObjectImpl(Object obj) { + Genre genre = (Genre) obj; + titleView.setText(genre.getName()); + + if(genre.getAlbumCount() != null) { + songsView.setVisibility(View.VISIBLE); + albumsView.setVisibility(View.VISIBLE); + songsView.setText(context.getResources().getString(R.string.select_genre_songs, genre.getSongCount())); + albumsView.setText(context.getResources().getString(R.string.select_genre_albums, genre.getAlbumCount())); + } else { + songsView.setVisibility(View.GONE); + albumsView.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/HeaderGridView.java b/app/src/main/java/github/daneren2005/dsub/view/HeaderGridView.java new file mode 100644 index 00000000..8a82f353 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/HeaderGridView.java @@ -0,0 +1,836 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package github.daneren2005.dsub.view; + +import android.annotation.TargetApi; +import android.content.Context; +import android.database.DataSetObservable; +import android.database.DataSetObserver; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; + +/** + * A {@link GridView} that supports adding header rows in a + * very similar way to {@link android.widget.ListView}. + * See {@link HeaderGridView#addHeaderView(View, Object, boolean)} + * See {@link HeaderGridView#addFooterView(View, Object, boolean)} + */ +public class HeaderGridView extends GridView { + private static final String TAG = HeaderGridView.class.getSimpleName(); + public static boolean DEBUG = false; + + /** + * A class that represents a fixed view in a list, for example a header at the top + * or a footer at the bottom. + */ + private static class FixedViewInfo { + /** + * The view to add to the grid + */ + public View view; + public ViewGroup viewContainer; + /** + * The data backing the view. This is returned from {@link ListAdapter#getItem(int)}. + */ + public Object data; + /** + * true if the fixed view should be selectable in the grid + */ + public boolean isSelectable; + } + + private int mNumColumns = AUTO_FIT; + private View mViewForMeasureRowHeight = null; + private int mRowHeight = -1; + private static final String LOG_TAG = HeaderGridView.class.getSimpleName(); + + private ArrayList mHeaderViewInfos = new ArrayList(); + private ArrayList mFooterViewInfos = new ArrayList(); + + private void initHeaderGridView() { + } + + public HeaderGridView(Context context) { + super(context); + initHeaderGridView(); + } + + public HeaderGridView(Context context, AttributeSet attrs) { + super(context, attrs); + initHeaderGridView(); + } + + public HeaderGridView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initHeaderGridView(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + ListAdapter adapter = getAdapter(); + if (adapter != null && adapter instanceof HeaderViewGridAdapter) { + ((HeaderViewGridAdapter) adapter).setNumColumns(getNumColumnsCompatible()); + ((HeaderViewGridAdapter) adapter).setRowHeight(getRowHeight()); + } + } + + @Override + public void setClipChildren(boolean clipChildren) { + // Ignore, since the header rows depend on not being clipped + } + + /** + * Do not call this method unless you know how it works. + * + * @param clipChildren + */ + public void setClipChildrenSupper(boolean clipChildren) { + super.setClipChildren(false); + } + + /** + * Add a fixed view to appear at the top of the grid. If addHeaderView is + * called more than once, the views will appear in the order they were + * added. Views added using this call can take focus if they want. + *

+ * NOTE: Call this before calling setAdapter. This is so HeaderGridView can wrap + * the supplied cursor with one that will also account for header views. + * + * @param v The view to add. + */ + public void addHeaderView(View v) { + addHeaderView(v, null, true); + } + + /** + * Add a fixed view to appear at the top of the grid. If addHeaderView is + * called more than once, the views will appear in the order they were + * added. Views added using this call can take focus if they want. + *

+ * NOTE: Call this before calling setAdapter. This is so HeaderGridView can wrap + * the supplied cursor with one that will also account for header views. + * + * @param v The view to add. + * @param data Data to associate with this view + * @param isSelectable whether the item is selectable + */ + public void addHeaderView(View v, Object data, boolean isSelectable) { + ListAdapter adapter = getAdapter(); + if (adapter != null && !(adapter instanceof HeaderViewGridAdapter)) { + throw new IllegalStateException( + "Cannot add header view to grid -- setAdapter has already been called."); + } + + ViewGroup.LayoutParams lyp = v.getLayoutParams(); + + FixedViewInfo info = new FixedViewInfo(); + FrameLayout fl = new FullWidthFixedViewLayout(getContext()); + + if (lyp != null) { + v.setLayoutParams(new FrameLayout.LayoutParams(lyp.width, lyp.height)); + fl.setLayoutParams(new AbsListView.LayoutParams(lyp.width, lyp.height)); + } + fl.addView(v); + info.view = v; + info.viewContainer = fl; + info.data = data; + info.isSelectable = isSelectable; + mHeaderViewInfos.add(info); + // in the case of re-adding a header view, or adding one later on, + // we need to notify the observer + if (adapter != null) { + ((HeaderViewGridAdapter) adapter).notifyDataSetChanged(); + } + } + + public void addFooterView(View v) { + addFooterView(v, null, true); + } + + public void addFooterView(View v, Object data, boolean isSelectable) { + ListAdapter mAdapter = getAdapter(); + if (mAdapter != null && !(mAdapter instanceof HeaderViewGridAdapter)) { + throw new IllegalStateException( + "Cannot add header view to grid -- setAdapter has already been called."); + } + + ViewGroup.LayoutParams lyp = v.getLayoutParams(); + + FixedViewInfo info = new FixedViewInfo(); + FrameLayout fl = new FullWidthFixedViewLayout(getContext()); + + if (lyp != null) { + v.setLayoutParams(new FrameLayout.LayoutParams(lyp.width, lyp.height)); + fl.setLayoutParams(new AbsListView.LayoutParams(lyp.width, lyp.height)); + } + fl.addView(v); + info.view = v; + info.viewContainer = fl; + info.data = data; + info.isSelectable = isSelectable; + mFooterViewInfos.add(info); + + if (mAdapter != null) { + ((HeaderViewGridAdapter) mAdapter).notifyDataSetChanged(); + } + } + + public int getHeaderViewCount() { + return mHeaderViewInfos.size(); + } + + public int getFooterViewCount() { + return mFooterViewInfos.size(); + } + + /** + * Removes a previously-added header view. + * + * @param v The view to remove + * @return true if the view was removed, false if the view was not a header + * view + */ + public boolean removeHeaderView(View v) { + if (mHeaderViewInfos.size() > 0) { + boolean result = false; + ListAdapter adapter = getAdapter(); + if (adapter != null && ((HeaderViewGridAdapter) adapter).removeHeader(v)) { + result = true; + } + removeFixedViewInfo(v, mHeaderViewInfos); + return result; + } + return false; + } + + /** + * Removes a previously-added footer view. + * + * @param v The view to remove + * @return true if the view was removed, false if the view was not a header + * view + */ + public boolean removeFooterView(View v) { + if (mFooterViewInfos.size() > 0) { + boolean result = false; + ListAdapter adapter = getAdapter(); + if (adapter != null && ((HeaderViewGridAdapter) adapter).removeFooter(v)) { + result = true; + } + removeFixedViewInfo(v, mFooterViewInfos); + return result; + } + return false; + } + + private void removeFixedViewInfo(View v, ArrayList where) { + int len = where.size(); + for (int i = 0; i < len; ++i) { + FixedViewInfo info = where.get(i); + if (info.view == v) { + where.remove(i); + break; + } + } + } + + @TargetApi(11) + private int getNumColumnsCompatible() { + if (Build.VERSION.SDK_INT >= 11) { + return super.getNumColumns(); + } else { + try { + Field numColumns = GridView.class.getSuperclass().getDeclaredField("mNumColumns"); + numColumns.setAccessible(true); + return numColumns.getInt(this); + } catch (Exception e) { + if (mNumColumns != -1) { + return mNumColumns; + } else { + return 2; + } + } + } + } + + @TargetApi(16) + private int getColumnWidthCompatible() { + if (Build.VERSION.SDK_INT >= 16) { + return super.getColumnWidth(); + } else { + try { + Field numColumns = getClass().getSuperclass().getDeclaredField("mColumnWidth"); + numColumns.setAccessible(true); + return numColumns.getInt(this); + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mViewForMeasureRowHeight = null; + } + + public void invalidateRowHeight() { + mRowHeight = -1; + } + + public int getHeaderHeight(int row) { + if (row >= 0) { + return mHeaderViewInfos.get(row).view.getMeasuredHeight(); + } + + return 0; + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + public int getVerticalSpacing(){ + int value = 0; + + try { + int currentapiVersion = android.os.Build.VERSION.SDK_INT; + if (currentapiVersion < Build.VERSION_CODES.JELLY_BEAN){ + Field field = this.getClass().getSuperclass().getDeclaredField("mVerticalSpacing"); + field.setAccessible(true); + value = field.getInt(this); + } else{ + value = super.getVerticalSpacing(); + } + + }catch (Exception ex){ + + } + + return value; + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + public int getHorizontalSpacing(){ + int value = 0; + + try { + int currentapiVersion = android.os.Build.VERSION.SDK_INT; + if (currentapiVersion < Build.VERSION_CODES.JELLY_BEAN){ + Field field = this.getClass().getSuperclass().getDeclaredField("mHorizontalSpacing"); + field.setAccessible(true); + value = field.getInt(this); + } else{ + value = super.getHorizontalSpacing(); + } + + }catch (Exception ex){ + + } + + return value; + } + + public int getRowHeight() { + if (mRowHeight > 0) { + // return mRowHeight; + } + ListAdapter adapter = getAdapter(); + int numColumns = getNumColumnsCompatible(); + + // adapter has not been set or has no views in it; + if (adapter == null || adapter.getCount() <= numColumns * (mHeaderViewInfos.size() + mFooterViewInfos.size()) || numColumns == -1) { + return -1; + } + int mColumnWidth = getColumnWidthCompatible(); + View view = getAdapter().getView(numColumns * mHeaderViewInfos.size(), mViewForMeasureRowHeight, this); + AbsListView.LayoutParams p = (AbsListView.LayoutParams) view.getLayoutParams(); + if (p == null) { + p = new AbsListView.LayoutParams(-1, -2, 0); + view.setLayoutParams(p); + } + int childHeightSpec = getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), 0, p.height); + int childWidthSpec = getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(mColumnWidth, MeasureSpec.EXACTLY), 0, p.width); + view.measure(childWidthSpec, childHeightSpec); + mViewForMeasureRowHeight = view; + mRowHeight = view.getMeasuredHeight(); + return mRowHeight; + } + + @TargetApi(11) + public void tryToScrollToBottomSmoothly() { + int lastPos = getAdapter().getCount() - 1; + if (Build.VERSION.SDK_INT >= 11) { + smoothScrollToPositionFromTop(lastPos, 0); + } else { + setSelection(lastPos); + } + } + + @TargetApi(11) + public void tryToScrollToBottomSmoothly(int duration) { + int lastPos = getAdapter().getCount() - 1; + if (Build.VERSION.SDK_INT >= 11) { + smoothScrollToPositionFromTop(lastPos, 0, duration); + } else { + setSelection(lastPos); + } + } + + @Override + public void setAdapter(ListAdapter adapter) { + if (mHeaderViewInfos.size() > 0 || mFooterViewInfos.size() > 0) { + HeaderViewGridAdapter headerViewGridAdapter = new HeaderViewGridAdapter(mHeaderViewInfos, mFooterViewInfos, adapter); + int numColumns = getNumColumnsCompatible(); + if (numColumns > 1) { + headerViewGridAdapter.setNumColumns(numColumns); + } + headerViewGridAdapter.setRowHeight(getRowHeight()); + super.setAdapter(headerViewGridAdapter); + } else { + super.setAdapter(adapter); + } + } + + /** + * full width + */ + private class FullWidthFixedViewLayout extends FrameLayout { + + public FullWidthFixedViewLayout(Context context) { + super(context); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + int realLeft = HeaderGridView.this.getPaddingLeft() + getPaddingLeft(); + // Try to make where it should be, from left, full width + if (realLeft != left) { + offsetLeftAndRight(realLeft - left); + } + super.onLayout(changed, left, top, right, bottom); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int targetWidth = HeaderGridView.this.getMeasuredWidth() + - HeaderGridView.this.getPaddingLeft() + - HeaderGridView.this.getPaddingRight(); + widthMeasureSpec = MeasureSpec.makeMeasureSpec(targetWidth, + MeasureSpec.getMode(widthMeasureSpec)); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + @Override + public void setNumColumns(int numColumns) { + super.setNumColumns(numColumns); + mNumColumns = numColumns; + ListAdapter adapter = getAdapter(); + if (adapter != null && adapter instanceof HeaderViewGridAdapter) { + ((HeaderViewGridAdapter) adapter).setNumColumns(numColumns); + } + } + + /** + * ListAdapter used when a HeaderGridView has header views. This ListAdapter + * wraps another one and also keeps track of the header views and their + * associated data objects. + *

This is intended as a base class; you will probably not need to + * use this class directly in your own code. + */ + private static class HeaderViewGridAdapter extends BaseAdapter implements WrapperListAdapter, Filterable { + // This is used to notify the container of updates relating to number of columns + // or headers changing, which changes the number of placeholders needed + private final DataSetObservable mDataSetObservable = new DataSetObservable(); + private final ListAdapter mAdapter; + static final ArrayList EMPTY_INFO_LIST = + new ArrayList(); + + // This ArrayList is assumed to NOT be null. + ArrayList mHeaderViewInfos; + ArrayList mFooterViewInfos; + private int mNumColumns = 1; + private int mRowHeight = -1; + boolean mAreAllFixedViewsSelectable; + private final boolean mIsFilterable; + private boolean mCachePlaceHoldView = true; + // From Recycle Bin or calling getView, this a question... + private boolean mCacheFirstHeaderView = false; + + public HeaderViewGridAdapter(ArrayList headerViewInfos, ArrayList footViewInfos, ListAdapter adapter) { + mAdapter = adapter; + mIsFilterable = adapter instanceof Filterable; + if (headerViewInfos == null) { + mHeaderViewInfos = EMPTY_INFO_LIST; + } else { + mHeaderViewInfos = headerViewInfos; + } + + if (footViewInfos == null) { + mFooterViewInfos = EMPTY_INFO_LIST; + } else { + mFooterViewInfos = footViewInfos; + } + mAreAllFixedViewsSelectable = areAllListInfosSelectable(mHeaderViewInfos) + && areAllListInfosSelectable(mFooterViewInfos); + } + + public void setNumColumns(int numColumns) { + if (numColumns < 1) { + return; + } + if (mNumColumns != numColumns) { + mNumColumns = numColumns; + notifyDataSetChanged(); + } + } + + public void setRowHeight(int height) { + mRowHeight = height; + } + + public int getHeadersCount() { + return mHeaderViewInfos.size(); + } + + public int getFootersCount() { + return mFooterViewInfos.size(); + } + + @Override + public boolean isEmpty() { + return (mAdapter == null || mAdapter.isEmpty()) && getHeadersCount() == 0 && getFootersCount() == 0; + } + + private boolean areAllListInfosSelectable(ArrayList infos) { + if (infos != null) { + for (FixedViewInfo info : infos) { + if (!info.isSelectable) { + return false; + } + } + } + return true; + } + + public boolean removeHeader(View v) { + for (int i = 0; i < mHeaderViewInfos.size(); i++) { + FixedViewInfo info = mHeaderViewInfos.get(i); + if (info.view == v) { + mHeaderViewInfos.remove(i); + mAreAllFixedViewsSelectable = + areAllListInfosSelectable(mHeaderViewInfos) && areAllListInfosSelectable(mFooterViewInfos); + mDataSetObservable.notifyChanged(); + return true; + } + } + return false; + } + + public boolean removeFooter(View v) { + for (int i = 0; i < mFooterViewInfos.size(); i++) { + FixedViewInfo info = mFooterViewInfos.get(i); + if (info.view == v) { + mFooterViewInfos.remove(i); + mAreAllFixedViewsSelectable = + areAllListInfosSelectable(mHeaderViewInfos) && areAllListInfosSelectable(mFooterViewInfos); + mDataSetObservable.notifyChanged(); + return true; + } + } + return false; + } + + @Override + public int getCount() { + if (mAdapter != null) { + return (getFootersCount() + getHeadersCount()) * mNumColumns + getAdapterAndPlaceHolderCount(); + } else { + return (getFootersCount() + getHeadersCount()) * mNumColumns; + } + } + + @Override + public boolean areAllItemsEnabled() { + if (mAdapter != null) { + return mAreAllFixedViewsSelectable && mAdapter.areAllItemsEnabled(); + } else { + return true; + } + } + + private int getAdapterAndPlaceHolderCount() { + final int adapterCount = (int) (Math.ceil(1f * mAdapter.getCount() / mNumColumns) * mNumColumns); + return adapterCount; + } + + @Override + public boolean isEnabled(int position) { + // Header (negative positions will throw an IndexOutOfBoundsException) + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (position < numHeadersAndPlaceholders) { + return position % mNumColumns == 0 + && mHeaderViewInfos.get(position / mNumColumns).isSelectable; + } + + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = getAdapterAndPlaceHolderCount(); + if (adjPosition < adapterCount) { + return adjPosition < mAdapter.getCount() && mAdapter.isEnabled(adjPosition); + } + } + + // Footer (off-limits positions will throw an IndexOutOfBoundsException) + final int footerPosition = adjPosition - adapterCount; + return footerPosition % mNumColumns == 0 + && mFooterViewInfos.get(footerPosition / mNumColumns).isSelectable; + } + + @Override + public Object getItem(int position) { + // Header (negative positions will throw an ArrayIndexOutOfBoundsException) + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (position < numHeadersAndPlaceholders) { + if (position % mNumColumns == 0) { + return mHeaderViewInfos.get(position / mNumColumns).data; + } + return null; + } + + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = getAdapterAndPlaceHolderCount(); + if (adjPosition < adapterCount) { + if (adjPosition < mAdapter.getCount()) { + return mAdapter.getItem(adjPosition); + } else { + return null; + } + } + } + + // Footer (off-limits positions will throw an IndexOutOfBoundsException) + final int footerPosition = adjPosition - adapterCount; + if (footerPosition % mNumColumns == 0) { + return mFooterViewInfos.get(footerPosition).data; + } else { + return null; + } + } + + @Override + public long getItemId(int position) { + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (mAdapter != null && position >= numHeadersAndPlaceholders) { + int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = mAdapter.getCount(); + if (adjPosition < adapterCount) { + return mAdapter.getItemId(adjPosition); + } + } + return -1; + } + + @Override + public boolean hasStableIds() { + if (mAdapter != null) { + return mAdapter.hasStableIds(); + } + return false; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (DEBUG) { + Log.d(LOG_TAG, String.format("getView: %s, reused: %s", position, convertView == null)); + } + // Header (negative positions will throw an ArrayIndexOutOfBoundsException) + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (position < numHeadersAndPlaceholders) { + View headerViewContainer = mHeaderViewInfos + .get(position / mNumColumns).viewContainer; + if (position % mNumColumns == 0) { + return headerViewContainer; + } else { + if (convertView == null) { + convertView = new View(parent.getContext()); + } + // We need to do this because GridView uses the height of the last item + // in a row to determine the height for the entire row. + convertView.setVisibility(View.INVISIBLE); + convertView.setMinimumHeight(headerViewContainer.getHeight()); + return convertView; + } + } + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = getAdapterAndPlaceHolderCount(); + if (adjPosition < adapterCount) { + if (adjPosition < mAdapter.getCount()) { + View view = mAdapter.getView(adjPosition, convertView, parent); + return view; + } else { + if (convertView == null) { + convertView = new View(parent.getContext()); + } + convertView.setVisibility(View.INVISIBLE); + convertView.setMinimumHeight(mRowHeight); + return convertView; + } + } + } + // Footer + final int footerPosition = adjPosition - adapterCount; + if (footerPosition < getCount()) { + View footViewContainer = mFooterViewInfos + .get(footerPosition / mNumColumns).viewContainer; + if (position % mNumColumns == 0) { + return footViewContainer; + } else { + if (convertView == null) { + convertView = new View(parent.getContext()); + } + // We need to do this because GridView uses the height of the last item + // in a row to determine the height for the entire row. + convertView.setVisibility(View.INVISIBLE); + convertView.setMinimumHeight(footViewContainer.getHeight()); + return convertView; + } + } + throw new ArrayIndexOutOfBoundsException(position); + } + + @Override + public int getItemViewType(int position) { + + final int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + final int adapterViewTypeStart = mAdapter == null ? 0 : mAdapter.getViewTypeCount() - 1; + int type = AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER; + if (mCachePlaceHoldView) { + // Header + if (position < numHeadersAndPlaceholders) { + if (position == 0) { + if (mCacheFirstHeaderView) { + type = adapterViewTypeStart + mHeaderViewInfos.size() + mFooterViewInfos.size() + 1 + 1; + } + } + if (position % mNumColumns != 0) { + type = adapterViewTypeStart + (position / mNumColumns + 1); + } + } + } + + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = getAdapterAndPlaceHolderCount(); + if (adjPosition >= 0 && adjPosition < adapterCount) { + if (adjPosition < mAdapter.getCount()) { + type = mAdapter.getItemViewType(adjPosition); + } else { + if (mCachePlaceHoldView) { + type = adapterViewTypeStart + mHeaderViewInfos.size() + 1; + } + } + } + } + + if (mCachePlaceHoldView) { + // Footer + final int footerPosition = adjPosition - adapterCount; + if (footerPosition >= 0 && footerPosition < getCount() && (footerPosition % mNumColumns) != 0) { + type = adapterViewTypeStart + mHeaderViewInfos.size() + 1 + (footerPosition / mNumColumns + 1); + } + } + if (DEBUG) { + Log.d(LOG_TAG, String.format("getItemViewType: pos: %s, result: %s", position, type, mCachePlaceHoldView, mCacheFirstHeaderView)); + } + return type; + } + + /** + * content view, content view holder, header[0], header and footer placeholder(s) + * + * @return + */ + @Override + public int getViewTypeCount() { + int count = mAdapter == null ? 1 : mAdapter.getViewTypeCount(); + if (mCachePlaceHoldView) { + int offset = mHeaderViewInfos.size() + 1 + mFooterViewInfos.size(); + if (mCacheFirstHeaderView) { + offset += 1; + } + count += offset; + } + if (DEBUG) { + Log.d(LOG_TAG, String.format("getViewTypeCount: %s", count)); + } + return count; + } + + @Override + public void registerDataSetObserver(DataSetObserver observer) { + mDataSetObservable.registerObserver(observer); + if (mAdapter != null) { + mAdapter.registerDataSetObserver(observer); + } + } + + @Override + public void unregisterDataSetObserver(DataSetObserver observer) { + mDataSetObservable.unregisterObserver(observer); + if (mAdapter != null) { + mAdapter.unregisterDataSetObserver(observer); + } + } + + @Override + public Filter getFilter() { + if (mIsFilterable) { + return ((Filterable) mAdapter).getFilter(); + } + return null; + } + + @Override + public ListAdapter getWrappedAdapter() { + return mAdapter; + } + + public void notifyDataSetChanged() { + mDataSetObservable.notifyChanged(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/MyLeadingMarginSpan2.java b/app/src/main/java/github/daneren2005/dsub/view/MyLeadingMarginSpan2.java new file mode 100644 index 00000000..20281a28 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/MyLeadingMarginSpan2.java @@ -0,0 +1,34 @@ +package github.daneren2005.dsub.view; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.style.LeadingMarginSpan; + +/** + * Created by Scott on 1/13/2015. + */ +public class MyLeadingMarginSpan2 implements LeadingMarginSpan.LeadingMarginSpan2 { + private int margin; + private int lines; + + public MyLeadingMarginSpan2(int lines, int margin) { + this.margin = margin; + this.lines = lines; + } + + @Override + public int getLeadingMargin(boolean first) { + return first ? margin : 0; + } + + @Override + public int getLeadingMarginLineCount() { + return lines; + } + + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, + int top, int baseline, int bottom, CharSequence text, + int start, int end, boolean first, Layout layout) {} +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/MyViewFlipper.java b/app/src/main/java/github/daneren2005/dsub/view/MyViewFlipper.java new file mode 100644 index 00000000..26a3de08 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/MyViewFlipper.java @@ -0,0 +1,53 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ViewFlipper; + +/** + * Work-around for Android Issue 6191 (http://code.google.com/p/android/issues/detail?id=6191) + * + * @author Sindre Mehus + * @version $Id$ + */ +public class MyViewFlipper extends ViewFlipper { + + public MyViewFlipper(Context context) { + super(context); + } + + public MyViewFlipper(Context context, AttributeSet attrs) { + super(context, attrs); + } + + + @Override + protected void onDetachedFromWindow() { + try { + super.onDetachedFromWindow(); + } + catch (IllegalArgumentException e) { + // Call stopFlipping() in order to kick off updateRunning() + stopFlipping(); + } + } +} + diff --git a/app/src/main/java/github/daneren2005/dsub/view/PlaylistSongView.java b/app/src/main/java/github/daneren2005/dsub/view/PlaylistSongView.java new file mode 100644 index 00000000..0264a785 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/PlaylistSongView.java @@ -0,0 +1,102 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.util.FileUtil; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.Util; + +public class PlaylistSongView extends UpdateView { + private static final String TAG = PlaylistSongView.class.getSimpleName(); + + private Context context; + private Playlist playlist; + + private TextView titleView; + private TextView countView; + private int count = 0; + private List songs; + + public PlaylistSongView(Context context) { + super(context, false); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.basic_count_item, this, true); + + titleView = (TextView) findViewById(R.id.basic_count_name); + countView = (TextView) findViewById(R.id.basic_count_count); + } + + protected void setObjectImpl(Object obj1, Object obj2) { + this.playlist = (Playlist) obj1; + this.songs = (List) obj2; + count = 0; + titleView.setText(playlist.getName()); + // Make sure to hide initially so it's not present briefly before update + countView.setVisibility(View.GONE); + } + + @Override + protected void updateBackground() { + // Make sure to reset when starting count + count = 0; + + // Don't try to lookup playlist for Create New + if(!"-1".equals(playlist.getId())) { + MusicDirectory cache = FileUtil.deserialize(context, Util.getCacheName(context, "playlist", playlist.getId()), MusicDirectory.class); + if(cache != null) { + // Try to find song instances in the given playlists + for(MusicDirectory.Entry song: songs) { + if(cache.getChildren().contains(song)) { + count++; + } + } + } + } + } + + @Override + protected void update() { + // Update count display with appropriate information + if(count <= 0) { + countView.setVisibility(View.GONE); + } else { + String displayName; + if(count < 10) { + displayName = "0" + count; + } else { + displayName = "" + count; + } + + countView.setText(displayName); + countView.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/PlaylistView.java b/app/src/main/java/github/daneren2005/dsub/view/PlaylistView.java new file mode 100644 index 00000000..25613984 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/PlaylistView.java @@ -0,0 +1,69 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.util.SyncUtil; + +/** + * Used to display albums in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class PlaylistView extends UpdateView { + private static final String TAG = PlaylistView.class.getSimpleName(); + + private Context context; + private Playlist playlist; + + private TextView titleView; + + public PlaylistView(Context context) { + super(context); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.basic_list_item, this, true); + + titleView = (TextView) findViewById(R.id.item_name); + starButton = (ImageButton) findViewById(R.id.item_star); + starButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.item_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + protected void setObjectImpl(Object obj) { + this.playlist = (Playlist) obj; + titleView.setText(playlist.getName()); + } + + @Override + protected void updateBackground() { + pinned = SyncUtil.isSyncedPlaylist(context, playlist.getId()); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/PodcastChannelView.java b/app/src/main/java/github/daneren2005/dsub/view/PodcastChannelView.java new file mode 100644 index 00000000..ada8019e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/PodcastChannelView.java @@ -0,0 +1,87 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.util.SyncUtil; +import github.daneren2005.dsub.util.FileUtil; +import java.io.File; + +public class PodcastChannelView extends UpdateView { + private static final String TAG = PodcastChannelView.class.getSimpleName(); + + private Context context; + private PodcastChannel channel; + private File file; + + private TextView titleView; + + public PodcastChannelView(Context context) { + super(context); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.basic_list_item, this, true); + + titleView = (TextView) findViewById(R.id.item_name); + starButton = (ImageButton) findViewById(R.id.item_star); + starButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.item_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + protected void setObjectImpl(Object obj) { + channel = (PodcastChannel) obj; + if(channel.getName() != null) { + titleView.setText(channel.getName()); + } else { + titleView.setText(channel.getUrl()); + } + file = FileUtil.getPodcastDirectory(context, channel); + } + + @Override + protected void updateBackground() { + if(SyncUtil.isSyncedPodcast(context, channel.getId())) { + if(exists) { + shaded = false; + exists = false; + } + pinned = true; + } else if(file.exists()) { + if(pinned) { + shaded = false; + pinned = false; + } + exists = true; + } else { + pinned = false; + exists = false; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/RecyclingImageView.java b/app/src/main/java/github/daneren2005/dsub/view/RecyclingImageView.java new file mode 100644 index 00000000..0c85697f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/RecyclingImageView.java @@ -0,0 +1,91 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2015 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.view; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.ImageView; + +public class RecyclingImageView extends ImageView { + public RecyclingImageView(Context context) { + super(context); + } + + public RecyclingImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public RecyclingImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public RecyclingImageView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public void onDraw(Canvas canvas) { + Drawable drawable = this.getDrawable(); + if(drawable != null) { + if(drawable instanceof BitmapDrawable) { + if (isBitmapRecycled(drawable)) { + this.setImageDrawable(null); + } + } else if(drawable instanceof TransitionDrawable) { + TransitionDrawable transitionDrawable = (TransitionDrawable) drawable; + + // If last bitmap in chain is recycled, just blank this out since it would be invalid anyways + Drawable lastDrawable = transitionDrawable.getDrawable(transitionDrawable.getNumberOfLayers() - 1); + if(isBitmapRecycled(lastDrawable)) { + this.setImageDrawable(null); + } else { + // Go through earlier bitmaps and make sure that they are not recycled + for (int i = 0; i < transitionDrawable.getNumberOfLayers(); i++) { + Drawable layerDrawable = transitionDrawable.getDrawable(i); + if (isBitmapRecycled(layerDrawable)) { + // If anything in the chain is broken, just get rid of transition and go to last drawable + this.setImageDrawable(lastDrawable); + break; + } + } + } + } + } + + super.onDraw(canvas); + } + + private boolean isBitmapRecycled(Drawable drawable) { + if(!(drawable instanceof BitmapDrawable)) { + return false; + } + + BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; + if (bitmapDrawable.getBitmap() != null && bitmapDrawable.getBitmap().isRecycled()) { + return true; + } else { + return false; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/SeekBarPreference.java b/app/src/main/java/github/daneren2005/dsub/view/SeekBarPreference.java new file mode 100644 index 00000000..fa8e8b3a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/SeekBarPreference.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2012 Christopher Eby + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.content.res.TypedArray; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.SeekBar; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.util.Constants; + +/** + * SeekBar preference to set the shake force threshold. + */ +public class SeekBarPreference extends DialogPreference implements SeekBar.OnSeekBarChangeListener { + private static final String TAG = SeekBarPreference.class.getSimpleName(); + /** + * The current value. + */ + private String mValue; + private int mMin; + private int mMax; + private float mStepSize; + private String mDisplay; + + /** + * Our context (needed for getResources()) + */ + private Context mContext; + + /** + * TextView to display current threshold. + */ + private TextView mValueText; + + public SeekBarPreference(Context context, AttributeSet attrs) + { + super(context, attrs); + mContext = context; + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SeekBarPreference); + mMin = a.getInteger(R.styleable.SeekBarPreference_min, 0); + mMax = a.getInteger(R.styleable.SeekBarPreference_max, 100); + mStepSize = a.getFloat(R.styleable.SeekBarPreference_stepSize, 1f); + mDisplay = a.getString(R.styleable.SeekBarPreference_display); + if(mDisplay == null) { + mDisplay = "%.0f"; + } + } + + @Override + public CharSequence getSummary() + { + return getSummary(mValue); + } + + @Override + protected Object onGetDefaultValue(TypedArray a, int index) + { + return a.getString(index); + } + + @Override + protected void onSetInitialValue(boolean restoreValue, Object defaultValue) + { + mValue = restoreValue ? getPersistedString((String) defaultValue) : (String)defaultValue; + } + + /** + * Create the summary for the given value. + * + * @param value The force threshold. + * @return A string representation of the threshold. + */ + private String getSummary(String value) { + try { + int val = Integer.parseInt(value); + return String.format(mDisplay, (val + mMin) / mStepSize); + } catch (Exception e) { + return ""; + } + } + + @Override + protected View onCreateDialogView() + { + View view = super.onCreateDialogView(); + + mValueText = (TextView)view.findViewById(R.id.value); + mValueText.setText(getSummary(mValue)); + + SeekBar seekBar = (SeekBar)view.findViewById(R.id.seek_bar); + seekBar.setMax(mMax - mMin); + try { + seekBar.setProgress(Integer.parseInt(mValue)); + } catch(Exception e) { + seekBar.setProgress(0); + } + seekBar.setOnSeekBarChangeListener(this); + + return view; + } + + @Override + protected void onDialogClosed(boolean positiveResult) + { + if(positiveResult) { + persistString(mValue); + notifyChanged(); + } + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) + { + if (fromUser) { + mValue = String.valueOf(progress); + mValueText.setText(getSummary(mValue)); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) + { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) + { + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/SettingView.java b/app/src/main/java/github/daneren2005/dsub/view/SettingView.java new file mode 100644 index 00000000..1c78706e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/SettingView.java @@ -0,0 +1,102 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckedTextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; + +import static github.daneren2005.dsub.domain.User.Setting; + +public class SettingView extends UpdateView { + Setting setting; + + CheckedTextView view; + + public SettingView(Context context) { + super(context, false); + this.context = context; + LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_multiple_choice, this, true); + + view = (CheckedTextView) findViewById(android.R.id.text1); + } + + protected void setObjectImpl(Object obj, Object editable) { + this.setting = (Setting) obj; + + // Can't edit non-role parts + String name = setting.getName(); + if(name.indexOf("Role") == -1) { + editable = false; + } + + int res = -1; + if(User.SCROBBLING.equals(name)) { + res = R.string.admin_scrobblingEnabled; + } else if(User.ADMIN.equals(name)) { + res = R.string.admin_role_admin; + } else if(User.SETTINGS.equals(name)) { + res = R.string.admin_role_settings; + } else if(User.DOWNLOAD.equals(name)) { + res = R.string.admin_role_download; + } else if(User.UPLOAD.equals(name)) { + res = R.string.admin_role_upload; + } else if(User.COVERART.equals(name)) { + res = R.string.admin_role_coverArt; + } else if(User.COMMENT.equals(name)) { + res = R.string.admin_role_comment; + } else if(User.PODCAST.equals(name)) { + res = R.string.admin_role_podcast; + } else if(User.STREAM.equals(name)) { + res = R.string.admin_role_stream; + } else if(User.JUKEBOX.equals(name)) { + res = R.string.admin_role_jukebox; + } else if(User.SHARE.equals(name)) { + res = R.string.admin_role_share; + } else if(User.LASTFM.equals(name)) { + res = R.string.admin_role_lastfm; + } else { + // Last resort to display the raw value + view.setText(name); + } + + if(res != -1) { + view.setText(res); + } + + if(setting.getValue()) { + view.setChecked(setting.getValue()); + } else { + view.setChecked(false); + } + + if((Boolean) editable) { + view.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + view.toggle(); + setting.setValue(view.isChecked()); + } + }); + } else { + view.setOnClickListener(null); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/ShareView.java b/app/src/main/java/github/daneren2005/dsub/view/ShareView.java new file mode 100644 index 00000000..bfb5b198 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/ShareView.java @@ -0,0 +1,65 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.Locale; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.Share; + +public class ShareView extends UpdateView { + private static final String TAG = ShareView.class.getSimpleName(); + + private TextView titleView; + private TextView descriptionView; + + public ShareView(Context context) { + super(context, false); + LayoutInflater.from(context).inflate(R.layout.complex_list_item, this, true); + + titleView = (TextView) findViewById(R.id.item_name); + descriptionView = (TextView) findViewById(R.id.item_description); + starButton = (ImageButton) findViewById(R.id.item_star); + starButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.item_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + public void setObjectImpl(Object obj) { + Share share = (Share) obj; + titleView.setText(share.getName()); + if(share.getExpires() != null) { + descriptionView.setText(context.getResources().getString(R.string.share_expires, new SimpleDateFormat("E MMM d, yyyy", Locale.ENGLISH).format(share.getExpires()))); + } else { + descriptionView.setText(context.getResources().getString(R.string.share_expires_never)); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/SongView.java b/app/src/main/java/github/daneren2005/dsub/view/SongView.java new file mode 100644 index 00000000..2fbaedc3 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/SongView.java @@ -0,0 +1,318 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.*; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.util.Util; + +import java.io.File; + +/** + * Used to display songs in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class SongView extends UpdateView implements Checkable { + private static final String TAG = SongView.class.getSimpleName(); + + private MusicDirectory.Entry song; + + private CheckedTextView checkedTextView; + private TextView titleTextView; + private TextView artistTextView; + private TextView durationTextView; + private TextView statusTextView; + private ImageView statusImageView; + private ImageView bookmarkButton; + private View bottomRowView; + + private DownloadService downloadService; + private long revision = -1; + private DownloadFile downloadFile; + private boolean dontChangeDownloadFile = false; + + private boolean playing = false; + private boolean rightImage = false; + private int moreImage = 0; + private boolean isWorkDone = false; + private boolean isSaved = false; + private File partialFile; + private boolean partialFileExists = false; + private boolean loaded = false; + private boolean isBookmarked = false; + private boolean bookmarked = false; + + public SongView(Context context) { + super(context); + LayoutInflater.from(context).inflate(R.layout.song_list_item, this, true); + + checkedTextView = (CheckedTextView) findViewById(R.id.song_check); + titleTextView = (TextView) findViewById(R.id.song_title); + artistTextView = (TextView) findViewById(R.id.song_artist); + durationTextView = (TextView) findViewById(R.id.song_duration); + statusTextView = (TextView) findViewById(R.id.song_status); + statusImageView = (ImageView) findViewById(R.id.song_status_icon); + ratingBar = (RatingBar) findViewById(R.id.song_rating); + starButton = (ImageButton) findViewById(R.id.song_star); + starButton.setFocusable(false); + bookmarkButton = (ImageButton) findViewById(R.id.song_bookmark); + bookmarkButton.setFocusable(false); + moreButton = (ImageView) findViewById(R.id.artist_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + bottomRowView = findViewById(R.id.song_bottom); + } + + public void setObjectImpl(Object obj1, Object obj2) { + this.song = (MusicDirectory.Entry) obj1; + boolean checkable = (Boolean) obj2; + + StringBuilder artist = new StringBuilder(40); + + boolean isPodcast = song instanceof PodcastEpisode; + if(!song.isVideo() || isPodcast) { + if(isPodcast) { + String date = ((PodcastEpisode)song).getDate(); + if(date != null) { + int index = date.indexOf(" "); + artist.append(date.substring(0, index != -1 ? index : date.length())); + } + } + else if(song.getArtist() != null) { + artist.append(song.getArtist()); + } + + if(isPodcast) { + String status = ((PodcastEpisode) song).getStatus(); + int statusRes = -1; + + if("error".equals(status)) { + statusRes = R.string.song_details_error; + } else if("skipped".equals(status)) { + statusRes = R.string.song_details_skipped; + } else if("downloading".equals(status)) { + statusRes = R.string.song_details_downloading; + } + + if(statusRes != -1) { + artist.append(" ("); + artist.append(getContext().getString(statusRes)); + artist.append(")"); + } + } + + durationTextView.setText(Util.formatDuration(song.getDuration())); + bottomRowView.setVisibility(View.VISIBLE); + } else { + bottomRowView.setVisibility(View.GONE); + statusTextView.setText(Util.formatDuration(song.getDuration())); + } + + String title = song.getTitle(); + Integer track = song.getTrack(); + if(track != null && Util.getDisplayTrack(context)) { + title = String.format("%02d", track) + " " + title; + } + + titleTextView.setText(title); + artistTextView.setText(artist); + checkedTextView.setVisibility(checkable && !song.isVideo() ? View.VISIBLE : View.GONE); + + this.setBackgroundColor(0x00000000); + ratingBar.setVisibility(View.GONE); + rating = 0; + + revision = -1; + loaded = false; + dontChangeDownloadFile = false; + } + + public void setDownloadFile(DownloadFile downloadFile) { + this.downloadFile = downloadFile; + dontChangeDownloadFile = true; + } + + public DownloadFile getDownloadFile() { + return downloadFile; + } + + @Override + protected void updateBackground() { + if (downloadService == null) { + downloadService = DownloadService.getInstance(); + if(downloadService == null) { + return; + } + } + + long newRevision = downloadService.getDownloadListUpdateRevision(); + if((revision != newRevision && dontChangeDownloadFile == false) || downloadFile == null) { + downloadFile = downloadService.forSong(song); + revision = newRevision; + } + + isWorkDone = downloadFile.isWorkDone(); + isSaved = downloadFile.isSaved(); + partialFile = downloadFile.getPartialFile(); + partialFileExists = partialFile.exists(); + isStarred = song.isStarred(); + isBookmarked = song.getBookmark() != null; + isRated = song.getRating(); + + // Check if needs to load metadata: check against all fields that we know are null in offline mode + if(song.getBitRate() == null && song.getDuration() == null && song.getDiscNumber() == null && isWorkDone) { + song.loadMetadata(downloadFile.getCompleteFile()); + loaded = true; + } + } + + @Override + protected void update() { + if(loaded) { + setObjectImpl(song, checkedTextView.getVisibility() == View.VISIBLE); + } + if (downloadService == null || downloadFile == null) { + return; + } + + if(song.isStarred()) { + if(!starred) { + starButton.setVisibility(View.VISIBLE); + starred = true; + } + } else { + if(starred) { + starButton.setVisibility(View.GONE); + starred = false; + } + } + + if (isWorkDone) { + int moreImage = isSaved ? R.drawable.download_pinned : R.drawable.download_cached; + if(moreImage != this.moreImage) { + moreButton.setImageResource(moreImage); + this.moreImage = moreImage; + } + } else if(this.moreImage != R.drawable.download_none_light) { + int[] attrs = new int[] {R.attr.download_none}; + TypedArray typedArray = context.obtainStyledAttributes(attrs); + moreButton.setImageResource(typedArray.getResourceId(0, 0)); + typedArray.recycle(); + this.moreImage = R.drawable.download_none_light; + } + + if (downloadFile.isDownloading() && !downloadFile.isDownloadCancelled() && partialFileExists) { + double percentage = (partialFile.length() * 100.0) / downloadFile.getEstimatedSize(); + percentage = Math.min(percentage, 100); + statusTextView.setText((int)percentage + " %"); + if(!rightImage) { + statusImageView.setVisibility(View.VISIBLE); + rightImage = true; + } + } else if(rightImage) { + statusTextView.setText(null); + statusImageView.setVisibility(View.GONE); + rightImage = false; + } + + boolean playing = downloadService.getCurrentPlaying() == downloadFile; + if (playing) { + if(!this.playing) { + this.playing = playing; + int[] attrs = new int[] {R.attr.media_button_start}; + TypedArray typedArray = context.obtainStyledAttributes(attrs); + titleTextView.setCompoundDrawablesWithIntrinsicBounds(typedArray.getResourceId(0, 0), 0, 0, 0); + } + } else { + if(this.playing) { + this.playing = playing; + titleTextView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + } + } + + if(isBookmarked) { + if(!bookmarked) { + bookmarkButton.setVisibility(View.VISIBLE); + bookmarked = true; + } + } else { + if(bookmarked) { + bookmarkButton.setVisibility(View.GONE); + bookmarked = false; + } + } + + if(isRated != rating) { + if(isRated > 1) { + if(rating <= 1) { + ratingBar.setVisibility(View.VISIBLE); + } + + ratingBar.setNumStars(isRated); + ratingBar.setRating(isRated); + } else if(isRated <= 1) { + if(rating > 1) { + ratingBar.setVisibility(View.GONE); + } + } + + // Still highlight red if a 1-star + if(isRated == 1) { + this.setBackgroundColor(Color.RED); + this.getBackground().setAlpha(20); + } else if(rating == 1) { + this.setBackgroundColor(0x00000000); + } + + rating = isRated; + } + } + + @Override + public void setChecked(boolean b) { + checkedTextView.setChecked(b); + } + + @Override + public boolean isChecked() { + return checkedTextView.isChecked(); + } + + @Override + public void toggle() { + checkedTextView.toggle(); + } + + public MusicDirectory.Entry getEntry() { + return song; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/SquareImageView.java b/app/src/main/java/github/daneren2005/dsub/view/SquareImageView.java new file mode 100644 index 00000000..66ab7d8d --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/SquareImageView.java @@ -0,0 +1,32 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + +public class SquareImageView extends RecyclingImageView { + public SquareImageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onMeasure(final int widthSpec, final int heightSpec) { + super.onMeasure(widthSpec, heightSpec); + setMeasuredDimension(getMeasuredWidth(), getMeasuredWidth()); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/UnscrollableGridView.java b/app/src/main/java/github/daneren2005/dsub/view/UnscrollableGridView.java new file mode 100644 index 00000000..3047d5d7 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/UnscrollableGridView.java @@ -0,0 +1,128 @@ +package github.daneren2005.dsub.view; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.AbsListView; +import android.widget.GridView; +import android.widget.ListAdapter; + +import java.lang.reflect.Field; + +/** + * Created by Scott on 4/26/2014. + */ +public class UnscrollableGridView extends GridView { + private static final String TAG = UnscrollableGridView.class.getSimpleName(); + + public UnscrollableGridView(Context context) { + super(context); + } + + public UnscrollableGridView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public UnscrollableGridView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public int getColumnWidth() { + // This method will be called from onMeasure() too. + // It's better to use getMeasuredWidth(), as it is safe in this case. + + int hSpacing = 20; + try { + Field field = GridView.class.getDeclaredField("mHorizontalSpacing"); + field.setAccessible(true); + hSpacing = field.getInt(this); + } catch(Exception e) { + + } + + final int totalHorizontalSpacing = getNumColumnsCompat() > 0 ? (getNumColumnsCompat() - 1) * hSpacing : 0; + return (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - totalHorizontalSpacing) / getNumColumnsCompat(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Sets the padding for this view. + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + final int measuredWidth = getMeasuredWidth(); + final int childWidth = getColumnWidth(); + int childHeight = 0; + + // If there's an adapter, use it to calculate the height of this view. + final ListAdapter adapter = getAdapter(); + final int count; + + // There shouldn't be any inherent size (due to padding) if there are no child views. + if (adapter == null || (count = adapter.getCount()) == 0) { + setMeasuredDimension(0, 0); + return; + } + + // Get the first child from the adapter. + final View child = adapter.getView(0, null, this); + if (child != null) { + // Set a default LayoutParams on the child, if it doesn't have one on its own. + AbsListView.LayoutParams params = (AbsListView.LayoutParams) child.getLayoutParams(); + if (params == null) { + params = new AbsListView.LayoutParams(AbsListView.LayoutParams.WRAP_CONTENT, + AbsListView.LayoutParams.WRAP_CONTENT); + child.setLayoutParams(params); + } + + // Measure the exact width of the child, and the height based on the width. + // Note: the child takes care of calculating its height. + int childWidthSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY); + int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + child.measure(childWidthSpec, childHeightSpec); + childHeight = child.getMeasuredHeight(); + } + + int vSpacing = 10; + try { + Field field = GridView.class.getDeclaredField("mVerticalSpacing"); + field.setAccessible(true); + vSpacing = field.getInt(this); + } catch(Exception e) { + + } + + // Number of rows required to 'mTotal' items. + final int rows = (int) Math.ceil((double) getCount() / getNumColumnsCompat()); + final int childrenHeight = childHeight * rows; + final int totalVerticalSpacing = rows > 0 ? (rows - 1) * vSpacing : 0; + + // Total height of this view. + final int measuredHeight = Math.abs(childrenHeight + getPaddingTop() + getPaddingBottom() + totalVerticalSpacing); + setMeasuredDimension(measuredWidth, measuredHeight); + } + + private int getNumColumnsCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + return getNumColumnsCompat11(); + } else { + int columns = 0; + int children = getChildCount(); + if (children > 0) { + int width = getChildAt(0).getMeasuredWidth(); + if (width > 0) { + columns = getWidth() / width; + } + } + return columns > 0 ? columns : AUTO_FIT; + } + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private int getNumColumnsCompat11() { + return getNumColumns(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/UpdateView.java b/app/src/main/java/github/daneren2005/dsub/view/UpdateView.java new file mode 100644 index 00000000..f9c62121 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/UpdateView.java @@ -0,0 +1,286 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RatingBar; + +import java.util.ArrayList; +import java.util.List; +import java.util.WeakHashMap; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ImageLoader; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.util.SilentBackgroundTask; + +public class UpdateView extends LinearLayout { + private static final String TAG = UpdateView.class.getSimpleName(); + private static final WeakHashMap INSTANCES = new WeakHashMap(); + + private static Handler backgroundHandler; + private static Handler uiHandler; + private static Runnable updateRunnable; + private static int activeActivities = 0; + + protected Context context; + protected RatingBar ratingBar; + protected ImageButton starButton; + protected ImageView moreButton; + + protected boolean exists = false; + protected boolean pinned = false; + protected boolean shaded = false; + protected boolean starred = false; + protected boolean isStarred = false; + protected int isRated = 0; + protected int rating = 0; + protected SilentBackgroundTask imageTask = null; + + protected final boolean autoUpdate; + + public UpdateView(Context context) { + this(context, true); + } + public UpdateView(Context context, boolean autoUpdate) { + super(context); + this.context = context; + this.autoUpdate = autoUpdate; + + setLayoutParams(new AbsListView.LayoutParams( + ViewGroup.LayoutParams.FILL_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + if(autoUpdate) { + INSTANCES.put(this, null); + } + startUpdater(); + } + + @Override + public void setPressed(boolean pressed) { + + } + + public void setObject(Object obj) { + setObjectImpl(obj); + updateBackground(); + update(); + } + public void setObject(Object obj1, Object obj2) { + if(imageTask != null) { + imageTask.cancel(); + imageTask = null; + } + + setObjectImpl(obj1, obj2); + backgroundHandler.post(new Runnable() { + @Override + public void run() { + updateBackground(); + uiHandler.post(new Runnable() { + @Override + public void run() { + update(); + } + }); + } + }); + } + protected void setObjectImpl(Object obj) { + + } + protected void setObjectImpl(Object obj1, Object obj2) { + + } + + private static synchronized void startUpdater() { + if(uiHandler != null) { + return; + } + + uiHandler = new Handler(); + // Needed so handler is never null until thread creates it + backgroundHandler = uiHandler; + updateRunnable = new Runnable() { + @Override + public void run() { + updateAll(); + } + }; + + new Thread(new Runnable() { + public void run() { + Looper.prepare(); + backgroundHandler = new Handler(Looper.myLooper()); + uiHandler.post(updateRunnable); + Looper.loop(); + } + }, "UpdateView").start(); + } + + public static synchronized void triggerUpdate() { + if(backgroundHandler != null) { + uiHandler.removeCallbacksAndMessages(null); + backgroundHandler.removeCallbacksAndMessages(null); + uiHandler.post(updateRunnable); + } + } + + private static void updateAll() { + try { + // If nothing can see this, stop updating + if(activeActivities == 0) { + activeActivities--; + return; + } + + List views = new ArrayList(); + for (UpdateView view : INSTANCES.keySet()) { + if (view.isShown()) { + views.add(view); + } + } + if(views.size() > 0) { + updateAllLive(views); + } else { + uiHandler.postDelayed(updateRunnable, 2000L); + } + } catch (Throwable x) { + Log.w(TAG, "Error when updating song views.", x); + } + } + private static void updateAllLive(final List views) { + final Runnable runnable = new Runnable() { + @Override + public void run() { + try { + for(UpdateView view: views) { + view.update(); + } + } catch (Throwable x) { + Log.w(TAG, "Error when updating song views.", x); + } + uiHandler.postDelayed(updateRunnable, 1000L); + } + }; + + backgroundHandler.post(new Runnable() { + @Override + public void run() { + try { + for(UpdateView view: views) { + view.updateBackground(); + } + uiHandler.post(runnable); + } catch (Throwable x) { + Log.w(TAG, "Error when updating song views.", x); + } + } + }); + } + + public static void addActiveActivity() { + activeActivities++; + + if(activeActivities == 0 && uiHandler != null && updateRunnable != null) { + activeActivities++; + uiHandler.post(updateRunnable); + } + } + public static void removeActiveActivity() { + activeActivities--; + } + + public static MusicDirectory.Entry findEntry(MusicDirectory.Entry entry) { + for(UpdateView view: INSTANCES.keySet()) { + MusicDirectory.Entry check = null; + if(view instanceof SongView) { + check = ((SongView) view).getEntry(); + } else if(view instanceof AlbumCell) { + check = ((AlbumCell) view).getEntry(); + } else if(view instanceof AlbumView) { + check = ((AlbumView) view).getEntry(); + } + + if(check != null && entry != check && check.getId().equals(entry.getId())) { + return check; + } + } + + return null; + } + + protected void updateBackground() { + + } + protected void update() { + if(moreButton != null) { + if(exists || pinned) { + if(!shaded) { + moreButton.setImageResource(exists ? R.drawable.download_cached : R.drawable.download_pinned); + shaded = true; + } + } else { + if(shaded) { + int[] attrs = new int[] {R.attr.download_none}; + TypedArray typedArray = context.obtainStyledAttributes(attrs); + moreButton.setImageResource(typedArray.getResourceId(0, 0)); + shaded = false; + } + } + } + + if(starButton != null) { + if(isStarred) { + if(!starred) { + starButton.setVisibility(View.VISIBLE); + starred = true; + } + } else { + if(starred) { + starButton.setVisibility(View.GONE); + starred = false; + } + } + } + + if(ratingBar != null && isRated != rating) { + if(isRated > 0 && rating == 0) { + ratingBar.setVisibility(View.VISIBLE); + } else if(isRated == 0 && rating > 0) { + ratingBar.setVisibility(View.GONE); + } + + ratingBar.setRating(isRated); + rating = isRated; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/view/UserView.java b/app/src/main/java/github/daneren2005/dsub/view/UserView.java new file mode 100644 index 00000000..dec8dbef --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/view/UserView.java @@ -0,0 +1,54 @@ +/* + This file is part of Subsonic. + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.util.ImageLoader; + +public class UserView extends UpdateView { + private User user; + + private TextView usernameView; + private ImageView avatarView; + + public UserView(Context context) { + super(context, false); + this.context = context; + LayoutInflater.from(context).inflate(R.layout.user_list_item, this, true); + + usernameView = (TextView) findViewById(R.id.item_name); + avatarView = (ImageView) findViewById(R.id.item_avatar); + moreButton = (ImageView) findViewById(R.id.item_more); + moreButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + v.showContextMenu(); + } + }); + } + + protected void setObjectImpl(Object obj, Object obj2) { + this.user = (User) obj; + usernameView.setText(user.getUsername()); + imageTask = ((ImageLoader)obj2).loadAvatar(context, avatarView, user.getUsername()); + } +} -- cgit v1.2.3