/* 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 <http://www.gnu.org/licenses/>. 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; 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) { super(downloadService); this.controlPoint = controlPoint; this.device = device; 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<String, StateVariableValue> m = genaSubscription.getCurrentValues(); try { String lastChangeText = m.get("LastChange").toString(); lastChangeText = lastChangeText.replace(",X_DLNA_SeekTime","").replace(",X_DLNA_SeekByte", ""); LastChange lastChange = new LastChange(new AVTransportLastChangeParser(), lastChangeText); if (lastChange.getEventedValue(0, AVTransportVariable.TransportState.class) == null) { return; } switch (lastChange.getEventedValue(0, AVTransportVariable.TransportState.class).getValue()) { case PLAYING: downloadService.setPlayerState(PlayerState.STARTED); 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.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); } } @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<String, String> songInfo = getSongInfo(currentPlaying); currentPlayingURI = songInfo.getFirst(); 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<String, String> 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<String, String> getSongInfo(final DownloadFile downloadFile) throws Exception { MusicDirectory.Entry song = downloadFile.getSong(); // Get url for entry MusicService musicService = MusicServiceFactory.getMusicService(downloadService); String url = getStreamUrl(musicService, downloadFile); // 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.onNextStarted(nextPlaying); nextPlayingURI = null; } 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); } } }