/*******************************************************************************
* Copyright (c) 2013 Jens Kristian Villadsen.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*
* Contributors:
* Jens Kristian Villadsen - Lead developer, owner and creator
******************************************************************************/
/*
TunesRemote+ - http://code.google.com/p/tunesremote-plus/
Copyright (C) 2008 Jeffrey Sharkey, http://jsharkey.org/
Copyright (C) 2010 TunesRemote+, http://code.google.com/p/tunesremote-plus/
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 <http://www.gnu.org/licenses/>.
The Initial Developer of the Original Code is Jeffrey Sharkey.
Portions created by Jeffrey Sharkey are
Copyright (C) 2008. Jeffrey Sharkey, http://jsharkey.org/
All Rights Reserved.
*/
package org.dyndns.jkiddo.service.daap.client;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import org.dyndns.jkiddo.dmcp.chunks.media.PlayingStatus;
import org.dyndns.jkiddo.dmcp.chunks.media.PropertyResponse;
import org.dyndns.jkiddo.dmcp.chunks.media.RelativeVolume;
import org.dyndns.jkiddo.dmcp.chunks.media.audio.PlayQueueContentsResponse;
import org.dyndns.jkiddo.dmcp.chunks.media.audio.SpeakerActive;
import org.dyndns.jkiddo.dmcp.chunks.media.audio.SpeakerList;
import org.dyndns.jkiddo.dmcp.chunks.media.audio.UnknownVD;
import org.dyndns.jkiddo.dmp.chunks.media.Dictionary;
import org.dyndns.jkiddo.dmp.chunks.media.ItemName;
import org.dyndns.jkiddo.dmp.chunks.media.MachineAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.Lists;
/**
* Status handles status information, including background timer thread also subscribes to keep-alive event updates.
* <p/>
*/
public class RemoteControl
{
private static final Logger LOGGER = LoggerFactory.getLogger(RemoteControl.class);
private final Session session;
RemoteControl(final Session session) throws Exception
{
this.session = session;
}
/**
* This call blocks until something happens in iTunes, eg. pushing play.
*
* @return
* @throws Exception
*/
public PlayingStatus getPlayStatusUpdateBlocking() throws Exception
{
// try fetching next revision update using socket keepalive
// approach
// using the next revision-number will make itunes keepalive
// until something happens
// http://192.168.254.128:3689/ctrl-int/1/playstatusupdate?revision-number=1&session-id=1034286700
return RequestHelper.requestParsed(String.format("%s/ctrl-int/1/playstatusupdate?revision-number=%d&session-id=%s", session.getRequestBase(), session.getRevision(), session.getSessionId()), true);
}
public PlayingStatus getPlayStatusUpdate() throws Exception
{
// using revision-number=1 will make sure we return
// instantly
// http://192.168.254.128:3689/ctrl-int/1/playstatusupdate?revision-number=1&session-id=1034286700
return RequestHelper.requestParsed(String.format("%s/ctrl-int/1/playstatusupdate?revision-number=%d&session-id=%s", session.getRequestBase(), 1, session.getSessionId()));
}
public byte[] fetchCover(final int imageWidth, final int imageHeight) throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/nowplayingartwork?mw=320&mh=320&session-id=1940361390
return RequestHelper.requestBitmap(String.format("%s/ctrl-int/1/nowplayingartwork?mw=" + imageWidth + "&mh=" + imageHeight + "&session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public long getMasterVolume() throws Exception
{
final PropertyResponse unknown = RequestHelper.requestParsed(String.format("%s/ctrl-int/1/getproperty?properties=dmcp.volume&session-id=%s", session.getRequestBase(), session.getSessionId()));
return unknown.getMasterVolume().getUnsignedValue();
}
/**
* Reads the list of available speakers
*
* @return list of available speakers
* @throws Exception
*/
public Collection<Speaker> getSpeakers() throws Exception
{
final List<Speaker> speakers = Lists.newArrayList();
final SpeakerList speakerList = RequestHelper.requestParsed(String.format("%s/ctrl-int/1/getspeakers?session-id=%s", session.getRequestBase(), session.getSessionId()));
for(final Dictionary dictonary : speakerList.getDictionaries())
{
final Speaker speaker = new Speaker();
final String name = dictonary.getSpecificChunk(ItemName.class).getValue();
final byte[] speakerId = dictonary.getSpecificChunk(MachineAddress.class).getValue();
final int relativeVolume = dictonary.getSpecificChunk(RelativeVolume.class).getValue();
final SpeakerActive isActive = dictonary.getSpecificChunk(SpeakerActive.class);
if(dictonary.getSpecificChunk(UnknownVD.class) != null)
{
dictonary.getSpecificChunk(UnknownVD.class).getValue();
}
speaker.setActive(isActive != null ? isActive.getBooleanValue() : false);
speaker.setId(speakerId);
speaker.setName(name);
speaker.setAbsoluteVolume(speaker.isActive() ? (int) getMasterVolume() * relativeVolume / 100 : 0);
speakers.add(speaker);
}
return speakers;
}
/**
* Sets (activates or deactivates) the speakers as defined in the given list.
*
* @param speakers
* all speakers to read the active flag from
* @throws Exception
*/
public void setSpeakers(final Collection<Speaker> speakers) throws Exception
{
StringBuilder idsString = new StringBuilder();
boolean first = true;
// The list of speakers to activate is a comma-separated string with
// the hex versions of the speakers' IDs
for(final Speaker speaker : speakers)
{
if(speaker.isActive())
{
if(!first)
{
idsString.append(",");
}
else
{
first = false;
}
idsString.append(speaker.getIdAsHex());
}
}
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setspeakers?speaker-id=%s&session-id=%s", session.getRequestBase(), idsString, session.getSessionId()));
}
/**
* Sets the volume of a single speaker. To recreate the behaviour of the original iOS Remote App, there are some additional information required because there is some hassle between relative and master volume.
*
* @param speakerId
* ID of the speaker to set the volume of
* @param newVolume
* the new volume to set
* @param formerVolume
* the former volume of this speaker
* @param speakersMaxVolume
* the maximum volume of all available speakers
* @param secondMaxVolume
* the volume of the second loudest speaker
* @param masterVolume
* the current master volume
* @throws Exception
*/
public void setSpeakerVolume(final byte[] speakerId, final int newVolume, final int formerVolume, final int speakersMaxVolume, final int secondMaxVolume, final long masterVolume) throws Exception
{
/*************************************************************
* If this speaker will become or is currently the loudest or is the only activated speaker, it will be controlled via the master volume.
*************************************************************/
if(newVolume > masterVolume || formerVolume == speakersMaxVolume)
{
if(newVolume < secondMaxVolume)
{
// First equalize the volume of this speaker with the second
// loudest
setAbsoluteVolume(speakerId, secondMaxVolume);
final int relativeVolume = newVolume * 100 / secondMaxVolume;
// then go on by decreasing the relative volume of this speaker
setRelativeVolume(speakerId, relativeVolume);
}
else
{
// the speaker will remain the loudest, so just control the
// absolute volume (master volume)
setAbsoluteVolume(speakerId, newVolume);
}
}
/*************************************************************
* Otherwise its relative volume will be controlled
*************************************************************/
else
{
final int relativeVolume = newVolume * 100 / (int) masterVolume;
setRelativeVolume(speakerId, relativeVolume);
}
}
/**
* Helper to control a speakers's absolute volume. This uses the URL parameters <code>setproperty?dmcp.volume=%d&include-speaker-id=%s</code> which results in iTunes controlling the master volume and the selected speaker synchronously.
*
* @param speakerId
* ID of the speaker to control
* @param absoluteVolume
* the volume to set absolutely
* @throws Exception
*/
private void setAbsoluteVolume(final byte[] speakerId, final int absoluteVolume) throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dmcp.volume=%d&include-speaker-id=%s" + "&session-id=%s", session.getRequestBase(), absoluteVolume, speakerId, session.getSessionId()));
}
/**
* Helper to control a speaker's relative volume. This relative volume is a value between 0 and 100 describing the relative volume of a speaker in comparison to the master volume. For this the URL parameters <code>%s/ctrl-int/1/setproperty?speaker-id=%s&dmcp.volume=%d</code> are used.
*
* @param speakerId
* ID of the speaker to control
* @param relativeVolume
* the relative volume to set
* @throws Exception
*/
private void setRelativeVolume(final byte[] speakerId, final int relativeVolume) throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?speaker-id=%s&dmcp.volume=%d" + "&session-id=%s", session.getRequestBase(), speakerId, relativeVolume, session.getSessionId()));
}
public PlayingStatus getNowPlaying(final String albumid) throws Exception
{
// Try Wilco (Alex W)'s nowplaying extension /ctrl-int/1/items
try
{
return RequestHelper.requestParsed(String.format("%s/ctrl-int/1/items?session-id=%s&meta=dmap.itemname,dmap.itemid,daap.songartist,daap.songalbum,daap.songalbum,daap.songtime,daap.songuserrating,daap.songtracknumber&type=music&sort=album&query='daap.songalbumid:%s'", session.getRequestBase(), session.getSessionId(), albumid), false);
}
catch(final IOException e)
{
LOGGER.debug(e.getMessage(), e);
return getNowPlaying();
}
}
public PlayingStatus getNowPlaying() throws Exception
{
// reads the current playing song as a one-item playlist
// Refactor response into one that looks like a normal items request
// and trigger listener
return RequestHelper.requestParsed(String.format("%s/ctrl-int/1/playstatusupdate?revision-number=1&session-id=%s", session.getRequestBase(), session.getSessionId(), false));
}
public void pause() throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/pause?session-id=130883770
RequestHelper.dispatch(String.format("%s/ctrl-int/1/pause?session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public void play() throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/playpause?session-id=130883770
RequestHelper.dispatch(String.format("%s/ctrl-int/1/playpause?session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public void next() throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/nextitem?session-id=130883770
RequestHelper.dispatch(String.format("%s/ctrl-int/1/nextitem?session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public void previous() throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/previtem?session-id=130883770
RequestHelper.dispatch(String.format("%s/ctrl-int/1/previtem?session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public void setVolume(final long volume) throws Exception
{
if(volume > 100 || volume < 0)
{
LOGGER.debug("Volume should be in the range 0 to 100");
}
// http://192.168.254.128:3689/ctrl-int/1/setproperty?dmcp.volume=100.000000&session-id=130883770
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dmcp.volume=%s&session-id=%s", session.getRequestBase(), volume, session.getSessionId()));
}
public void setProgress(final int progressSeconds) throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/setproperty?dacp.playingtime=82784&session-id=130883770
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dacp.playingtime=%d&session-id=%s", session.getRequestBase(), progressSeconds * 1000, session.getSessionId()));
}
public void setShuffle(final int shuffleMode) throws Exception
{
// /ctrl-int/1/setproperty?dacp.shufflestate=1&session-id=1873217009
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dacp.shufflestate=%d&session-id=%s", session.getRequestBase(), shuffleMode, session.getSessionId()));
}
public void setRepeat(final int repeatMode) throws Exception
{
// /ctrl-int/1/setproperty?dacp.repeatstate=2&session-id=1873217009
// HTTP/1.1
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dacp.repeatstate=%d&session-id=%s", session.getRequestBase(), repeatMode, session.getSessionId()));
}
/**
* Sets the rating stars of a particular song 0-100.
* <p/>
*
* @param rating
* the rating 0-100 to set for rating stars
* @param trackId
* the id of the track to update the rating for
* @throws Exception
*/
public void setRating(final long rating, final long trackId) throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dacp.userrating=%d&song-spec='dmap.itemid:%d'&session-id=%s", session.getRequestBase(), rating, trackId, session.getSessionId()));
}
/**
* Command to clear the Now Playing cue.
*
* @throws Exception
*/
private void clearCue() throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=clear&session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public void playAlbum(final long albumId, final int tracknum) throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/cue?command=clear&session-id=130883770
// http://192.168.254.128:3689/ctrl-int/1/cue?command=play&query=(('com.apple.itunes.mediakind:1','com.apple.itunes.mediakind:32')+'daap.songartist:Family%20Force%205')&index=0&sort=album&session-id=130883770
// /ctrl-int/1/cue?command=play&query='daap.songalbumid:16621530181618739404'&index=11&sort=album&session-id=514488449
// GET
// /ctrl-int/1/playspec?database-spec='dmap.persistentid:16621530181618731553'&playlist-spec='dmap.persistentid:9378496334192532210'&dacp.shufflestate=1&session-id=514488449
// (zero based index into playlist)
clearCue();
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=play&query='daap.songalbumid:%s'&index=%d&sort=album&session-id=%s", session.getRequestBase(), albumId, tracknum, session.getSessionId()));
}
public void queueAlbum(final long albumId) throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=add&query='daap.songalbumid:%s'&session-id=%s", session.getRequestBase(), albumId, session.getSessionId()));
}
public void playArtist(final String artist, final int index) throws Exception
{
// http://192.168.254.128:3689/ctrl-int/1/cue?command=clear&session-id=130883770
// /ctrl-int/1/cue?command=play&query=(('com.apple.itunes.mediakind:1','com.apple.itunes.mediakind:32')+'daap.songartist:Family%20Force%205')&index=0&sort=album&session-id=130883770
// /ctrl-int/1/cue?command=play&query='daap.songartist:%s'&index=0&sort=album&session-id=%s
final String encodedArtist = RequestHelper.escapeUrlString(artist);
final int encodedIndex = index;
clearCue();
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=play&query='daap.songartist:%s'&index=%d&sort=album&session-id=%s", session.getRequestBase(), encodedArtist, encodedIndex, session.getSessionId()));
}
public void queueArtist(final String artist) throws Exception
{
final String encodedArtist = RequestHelper.escapeUrlString(artist);
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=add&query='daap.songartist:%s'&session-id=%s", session.getRequestBase(), encodedArtist, session.getSessionId()));
}
public void queueTrack(final long trackId) throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=add&query='dmap.itemid:%s'&session-id=%s", session.getRequestBase(), trackId, session.getSessionId()));
}
public void playTrack(final long trackId) throws Exception
{
clearCue();
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=play&query='dmap.itemid:%s'&session-id=%s", session.getRequestBase(), trackId, session.getSessionId()));
}
public void playSearch(final String search, final int index) throws Exception
{
// /ctrl-int/1/cue?command=play&query=(('com.apple.itunes.mediakind:1','com.apple.itunes.mediakind:4','com.apple.itunes.mediakind:8')+'dmap.itemname:*F*')&index=4&sort=name&session-id=1550976127
final String encodedSearch = RequestHelper.escapeUrlString(search);
clearCue();
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=play&query=(('com.apple.itunes.mediakind:1','com.apple.itunes.mediakind:4','com.apple.itunes.mediakind:8')+('dmap.itemname:*%s*','daap.songartist:*%s*','daap.songalbum:*%s*'))&type=music&sort=name&index=%d&session-id=%s", session.getRequestBase(), encodedSearch, encodedSearch, encodedSearch, index, session.getSessionId()));
}
public void playPlaylist(final String playlistPersistentId, final String containerItemId) throws Exception
{
// /ctrl-int/1/playspec?database-spec='dmap.persistentid:0x9031099074C14E05'&container-spec='dmap.persistentid:0xA1E1854E0B9A1B'&container-item-spec='dmap.containeritemid:0x1b47'&session-id=7491138
RequestHelper.dispatch(String.format("%s/ctrl-int/1/playspec?database-spec='dmap.persistentid:0x%s'&container-spec='dmap.persistentid:0x%s'&container-item-spec='dmap.containeritemid:0x%s'&session-id=%s", session.getRequestBase(), session.getDatabase().getPersistentId(), playlistPersistentId, containerItemId, session.getSessionId()));
}
public void playIndex(final String albumid, final int tracknum) throws Exception
{
try
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=play&index=%d&sort=album&session-id=%s", session.getRequestBase(), tracknum, session.getSessionId()));
// on iTunes this generates a 501 Not Implemented response
}
catch(final Exception e)
{
if(albumid != null && albumid.length() > 0)
{
// Fall back to choosing from the current album if there is
// one
clearCue();
RequestHelper.dispatch(String.format("%s/ctrl-int/1/cue?command=play&query='daap.songalbumid:%s'&index=%d&sort=album&session-id=%s", session.getRequestBase(), albumid, tracknum, session.getSessionId()));
}
}
}
public void setVisualiser(final boolean enabled) throws Exception
{
// GET /ctrl-int/1/setproperty?dacp.visualizer=1&session-id=283658916
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dacp.visualizer=%d&session-id=%s", session.getRequestBase(), enabled ? 1 : 0, session.getSessionId()));
}
public void setFullscreen(final boolean enabled) throws Exception
{
// GET /ctrl-int/1/setproperty?dacp.fullscreen=1&session-id=283658916
RequestHelper.dispatch(String.format("%s/ctrl-int/1/setproperty?dacp.fullscreen=%d&session-id=%s", session.getRequestBase(), enabled ? 1 : 0, session.getSessionId()));
}
public void playRadio(final long genreId, final long itemId) throws Exception
{
playSpec(session.getRadioDatabase().getItemId(), genreId, itemId);
}
public void playSpec(final long databaseId, final long containerId, final long itemId) throws Exception
{
// GET
// /ctrl-int/1/playspec?database-spec='dmap.itemid:0x6073'&container-spec='dmap.itemid:0x607B'&item-spec='dmap.itemid:0x7cbe'&session-id=345827905
RequestHelper.dispatch(String.format("%s/ctrl-int/1/playspec?" + "database-spec='dmap.itemid:0x%x'" + "&container-spec='dmap.itemid:0x%x'" + "&item-spec='dmap.itemid:0x%x'" + "&session-id=%s", session.getRequestBase(), databaseId, containerId, itemId, session.getSessionId()));
}
public void playQueueEdit(final long itemID, final long playlistId) throws Exception
{
RequestHelper.dispatch(String.format("%s/ctrl-int/1/playqueue-edit?command=add&query='dmap.itemid:" + itemID + "'&queuefilter=playlist:" + playlistId + "&sort=name&mode=1&session-id=%s", session.getRequestBase(), session.getSessionId()));
}
public PlayQueueContentsResponse playQueue(final long span) throws Exception
{
return RequestHelper.requestParsed(String.format("%s/ctrl-int/1/playqueue-contents?span="+span+"&session-id=%s" , session.getRequestBase(), session.getSessionId()));
}
}