]> git.basschouten.com Git - openhab-addons.git/blob
e87043514b9e0ecda14f04b15936d50cc00730dd
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.squeezebox.internal.handler;
14
15 import static org.openhab.binding.squeezebox.internal.SqueezeBoxBindingConstants.*;
16
17 import java.net.URI;
18 import java.net.URISyntaxException;
19 import java.time.Duration;
20 import java.util.ArrayList;
21 import java.util.Collections;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Set;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28
29 import org.eclipse.jdt.annotation.NonNull;
30 import org.openhab.binding.squeezebox.internal.SqueezeBoxStateDescriptionOptionsProvider;
31 import org.openhab.binding.squeezebox.internal.config.SqueezeBoxPlayerConfig;
32 import org.openhab.binding.squeezebox.internal.model.Favorite;
33 import org.openhab.binding.squeezebox.internal.utils.SqueezeBoxTimeoutException;
34 import org.openhab.core.cache.ExpiringCacheMap;
35 import org.openhab.core.io.net.http.HttpUtil;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.IncreaseDecreaseType;
38 import org.openhab.core.library.types.NextPreviousType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.PercentType;
41 import org.openhab.core.library.types.PlayPauseType;
42 import org.openhab.core.library.types.RawType;
43 import org.openhab.core.library.types.RewindFastforwardType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.ThingStatusInfo;
50 import org.openhab.core.thing.ThingTypeUID;
51 import org.openhab.core.thing.binding.BaseThingHandler;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.RefreshType;
54 import org.openhab.core.types.State;
55 import org.openhab.core.types.StateOption;
56 import org.openhab.core.types.UnDefType;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 /**
61  * The {@link SqueezeBoxPlayerHandler} is responsible for handling states, which
62  * are sent to/from channels.
63  *
64  * @author Dan Cunningham - Initial contribution
65  * @author Mark Hilbush - Improved handling of player status, prevent REFRESH from causing exception
66  * @author Mark Hilbush - Implement AudioSink and notifications
67  * @author Mark Hilbush - Added duration channel
68  * @author Patrik Gfeller - Timeout for TTS messages increased from 30 to 90s.
69  * @author Mark Hilbush - Get favorites from server and play favorite
70  * @author Mark Hilbush - Convert sound notification volume from channel to config parameter
71  * @author Mark Hilbush - Add like/unlike functionality
72  */
73 public class SqueezeBoxPlayerHandler extends BaseThingHandler implements SqueezeBoxPlayerEventListener {
74     private final Logger logger = LoggerFactory.getLogger(SqueezeBoxPlayerHandler.class);
75
76     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(SQUEEZEBOXPLAYER_THING_TYPE);
77
78     /**
79      * We need to remember some states to change offsets in volume, time index,
80      * etc..
81      */
82     protected Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
83
84     /**
85      * Keeps current track time
86      */
87     private ScheduledFuture<?> timeCounterJob;
88
89     /**
90      * Local reference to our bridge
91      */
92     private SqueezeBoxServerHandler squeezeBoxServerHandler;
93
94     /**
95      * Our mac address, needed everywhere
96      */
97     private String mac;
98
99     /**
100      * The server sends us the current time on play/pause/stop events, we
101      * increment it locally from there on
102      */
103     private int currentTime = 0;
104
105     /**
106      * Our we playing something right now or not, need to keep current track
107      * time
108      */
109     private boolean playing;
110
111     /**
112      * Separate volume level for notifications
113      */
114     private Integer notificationSoundVolume = null;
115
116     private String callbackUrl;
117
118     private SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider;
119
120     private static final ExpiringCacheMap<String, RawType> IMAGE_CACHE = new ExpiringCacheMap<>(
121             TimeUnit.MINUTES.toMillis(15)); // 15min
122
123     private String likeCommand;
124     private String unlikeCommand;
125     private boolean connected = false;
126
127     /**
128      * Creates SqueezeBox Player Handler
129      *
130      * @param thing
131      * @param stateDescriptionProvider
132      */
133     public SqueezeBoxPlayerHandler(@NonNull Thing thing, String callbackUrl,
134             SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider) {
135         super(thing);
136         this.callbackUrl = callbackUrl;
137         this.stateDescriptionProvider = stateDescriptionProvider;
138     }
139
140     @Override
141     public void initialize() {
142         mac = getConfig().as(SqueezeBoxPlayerConfig.class).mac;
143         timeCounter();
144         updateThingStatus();
145         logger.debug("player thing {} initialized with mac {}", getThing().getUID(), mac);
146         if (squeezeBoxServerHandler != null) {
147             // ensure we get an up-to-date connection state
148             squeezeBoxServerHandler.requestPlayers();
149         }
150     }
151
152     @Override
153     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
154         updateThingStatus();
155     }
156
157     private void updateThingStatus() {
158         Thing bridge = getBridge();
159         if (bridge != null) {
160             squeezeBoxServerHandler = (SqueezeBoxServerHandler) bridge.getHandler();
161             ThingStatus bridgeStatus = bridge.getStatus();
162
163             if (bridgeStatus == ThingStatus.OFFLINE) {
164                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
165             } else if (!this.connected) {
166                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
167             } else if (bridgeStatus == ThingStatus.ONLINE && getThing().getStatus() != ThingStatus.ONLINE) {
168                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
169             }
170         } else {
171             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not found");
172         }
173     }
174
175     @Override
176     public void dispose() {
177         // stop our duration counter
178         if (timeCounterJob != null && !timeCounterJob.isCancelled()) {
179             timeCounterJob.cancel(true);
180             timeCounterJob = null;
181         }
182
183         if (squeezeBoxServerHandler != null) {
184             squeezeBoxServerHandler.removePlayerCache(mac);
185         }
186         logger.debug("player thing {} disposed for mac {}", getThing().getUID(), mac);
187         super.dispose();
188     }
189
190     @Override
191     public void handleCommand(ChannelUID channelUID, Command command) {
192         if (squeezeBoxServerHandler == null) {
193             logger.debug("Player {} has no server configured, ignoring command: {}", getThing().getUID(), command);
194             return;
195         }
196         // Some of the code below is not designed to handle REFRESH, only reply to channels where cached values exist
197         if (command == RefreshType.REFRESH) {
198             String channelID = channelUID.getId();
199             State newState = stateMap.get(channelID);
200             if (newState != null) {
201                 updateState(channelID, newState);
202             }
203             return;
204         }
205
206         switch (channelUID.getIdWithoutGroup()) {
207             case CHANNEL_POWER:
208                 if (command.equals(OnOffType.ON)) {
209                     squeezeBoxServerHandler.powerOn(mac);
210                 } else {
211                     squeezeBoxServerHandler.powerOff(mac);
212                 }
213                 break;
214             case CHANNEL_MUTE:
215                 if (command.equals(OnOffType.ON)) {
216                     squeezeBoxServerHandler.mute(mac);
217                 } else {
218                     squeezeBoxServerHandler.unMute(mac);
219                 }
220                 break;
221             case CHANNEL_STOP:
222                 if (command.equals(OnOffType.ON)) {
223                     squeezeBoxServerHandler.stop(mac);
224                 } else if (command.equals(OnOffType.OFF)) {
225                     squeezeBoxServerHandler.play(mac);
226                 }
227                 break;
228             case CHANNEL_PLAY_PAUSE:
229                 if (command.equals(OnOffType.ON)) {
230                     squeezeBoxServerHandler.play(mac);
231                 } else if (command.equals(OnOffType.OFF)) {
232                     squeezeBoxServerHandler.pause(mac);
233                 }
234                 break;
235             case CHANNEL_PREV:
236                 if (command.equals(OnOffType.ON)) {
237                     squeezeBoxServerHandler.prev(mac);
238                 }
239                 break;
240             case CHANNEL_NEXT:
241                 if (command.equals(OnOffType.ON)) {
242                     squeezeBoxServerHandler.next(mac);
243                 }
244                 break;
245             case CHANNEL_VOLUME:
246                 if (command instanceof PercentType percentCommand) {
247                     squeezeBoxServerHandler.setVolume(mac, percentCommand.intValue());
248                 } else if (command.equals(IncreaseDecreaseType.INCREASE)) {
249                     squeezeBoxServerHandler.volumeUp(mac, currentVolume());
250                 } else if (command.equals(IncreaseDecreaseType.DECREASE)) {
251                     squeezeBoxServerHandler.volumeDown(mac, currentVolume());
252                 } else if (command.equals(OnOffType.OFF)) {
253                     squeezeBoxServerHandler.mute(mac);
254                 } else if (command.equals(OnOffType.ON)) {
255                     squeezeBoxServerHandler.unMute(mac);
256                 }
257                 break;
258             case CHANNEL_CONTROL:
259                 if (command instanceof PlayPauseType) {
260                     if (command.equals(PlayPauseType.PLAY)) {
261                         squeezeBoxServerHandler.play(mac);
262                     } else if (command.equals(PlayPauseType.PAUSE)) {
263                         squeezeBoxServerHandler.pause(mac);
264                     }
265                 }
266                 if (command instanceof NextPreviousType) {
267                     if (command.equals(NextPreviousType.NEXT)) {
268                         squeezeBoxServerHandler.next(mac);
269                     } else if (command.equals(NextPreviousType.PREVIOUS)) {
270                         squeezeBoxServerHandler.prev(mac);
271                     }
272                 }
273                 if (command instanceof RewindFastforwardType) {
274                     if (command.equals(RewindFastforwardType.REWIND)) {
275                         squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() - 5);
276                     } else if (command.equals(RewindFastforwardType.FASTFORWARD)) {
277                         squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() + 5);
278                     }
279                 }
280                 break;
281             case CHANNEL_STREAM:
282                 squeezeBoxServerHandler.playUrl(mac, command.toString());
283                 break;
284             case CHANNEL_SYNC:
285                 if (command.toString().isBlank()) {
286                     squeezeBoxServerHandler.unSyncPlayer(mac);
287                 } else {
288                     squeezeBoxServerHandler.syncPlayer(mac, command.toString());
289                 }
290                 break;
291             case CHANNEL_UNSYNC:
292                 if (command.equals(OnOffType.ON)) {
293                     squeezeBoxServerHandler.unSyncPlayer(mac);
294                 }
295                 break;
296             case CHANNEL_PLAYLIST_INDEX:
297                 squeezeBoxServerHandler.playPlaylistItem(mac, ((DecimalType) command).intValue());
298                 break;
299             case CHANNEL_CURRENT_PLAYING_TIME:
300                 squeezeBoxServerHandler.setPlayingTime(mac, ((DecimalType) command).intValue());
301                 break;
302             case CHANNEL_CURRENT_PLAYLIST_SHUFFLE:
303                 squeezeBoxServerHandler.setShuffleMode(mac, ((DecimalType) command).intValue());
304                 break;
305             case CHANNEL_CURRENT_PLAYLIST_REPEAT:
306                 squeezeBoxServerHandler.setRepeatMode(mac, ((DecimalType) command).intValue());
307                 break;
308             case CHANNEL_FAVORITES_PLAY:
309                 squeezeBoxServerHandler.playFavorite(mac, command.toString());
310                 break;
311             case CHANNEL_RATE:
312                 if (command.equals(OnOffType.ON)) {
313                     squeezeBoxServerHandler.rate(mac, likeCommand);
314                 } else if (command.equals(OnOffType.OFF)) {
315                     squeezeBoxServerHandler.rate(mac, unlikeCommand);
316                 }
317                 break;
318             case CHANNEL_SLEEP:
319                 if (command instanceof DecimalType decimalCommand) {
320                     Duration sleepDuration = Duration.ofMinutes(decimalCommand.longValue());
321                     if (sleepDuration.isNegative() || sleepDuration.compareTo(Duration.ofDays(1)) > 0) {
322                         logger.debug("Sleep timer of {} minutes must be >= 0 and <= 1 day", sleepDuration.toMinutes());
323                         return;
324                     }
325                     squeezeBoxServerHandler.sleep(mac, sleepDuration);
326                 }
327                 break;
328             default:
329                 break;
330         }
331     }
332
333     @Override
334     public void playerAdded(SqueezeBoxPlayer player) {
335         // Player properties are saved in SqueezeBoxPlayerDiscoveryParticipant
336     }
337
338     @Override
339     public void powerChangeEvent(String mac, boolean power) {
340         updateChannel(mac, CHANNEL_POWER, power ? OnOffType.ON : OnOffType.OFF);
341         if (!power && isMe(mac)) {
342             playing = false;
343         }
344     }
345
346     @Override
347     public synchronized void modeChangeEvent(String mac, String mode) {
348         updateChannel(mac, CHANNEL_CONTROL, "play".equals(mode) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
349         updateChannel(mac, CHANNEL_PLAY_PAUSE, "play".equals(mode) ? OnOffType.ON : OnOffType.OFF);
350         updateChannel(mac, CHANNEL_STOP, "stop".equals(mode) ? OnOffType.ON : OnOffType.OFF);
351         if (isMe(mac)) {
352             playing = "play".equalsIgnoreCase(mode);
353         }
354     }
355
356     @Override
357     public void sourceChangeEvent(String mac, String source) {
358         updateChannel(mac, CHANNEL_SOURCE, StringType.valueOf(source));
359     }
360
361     @Override
362     public void absoluteVolumeChangeEvent(String mac, int volume) {
363         int newVolume = volume;
364         newVolume = Math.min(100, newVolume);
365         newVolume = Math.max(0, newVolume);
366         updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
367     }
368
369     @Override
370     public void relativeVolumeChangeEvent(String mac, int volumeChange) {
371         int newVolume = currentVolume() + volumeChange;
372         newVolume = Math.min(100, newVolume);
373         newVolume = Math.max(0, newVolume);
374         updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
375
376         if (isMe(mac)) {
377             logger.trace("Volume changed [{}] for player {}. New volume: {}", volumeChange, mac, newVolume);
378         }
379     }
380
381     @Override
382     public void muteChangeEvent(String mac, boolean mute) {
383         updateChannel(mac, CHANNEL_MUTE, mute ? OnOffType.ON : OnOffType.OFF);
384     }
385
386     @Override
387     public void currentPlaylistIndexEvent(String mac, int index) {
388         updateChannel(mac, CHANNEL_PLAYLIST_INDEX, new DecimalType(index));
389     }
390
391     @Override
392     public void currentPlayingTimeEvent(String mac, int time) {
393         updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new DecimalType(time));
394         if (isMe(mac)) {
395             currentTime = time;
396         }
397     }
398
399     @Override
400     public void durationEvent(String mac, int duration) {
401         if (getThing().getChannel(CHANNEL_DURATION) == null) {
402             logger.debug("Channel 'duration' does not exist.  Delete and readd player thing to pick up channel.");
403             return;
404         }
405         updateChannel(mac, CHANNEL_DURATION, new DecimalType(duration));
406     }
407
408     @Override
409     public void numberPlaylistTracksEvent(String mac, int track) {
410         updateChannel(mac, CHANNEL_NUMBER_PLAYLIST_TRACKS, new DecimalType(track));
411     }
412
413     @Override
414     public void currentPlaylistShuffleEvent(String mac, int shuffle) {
415         updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_SHUFFLE, new DecimalType(shuffle));
416     }
417
418     @Override
419     public void currentPlaylistRepeatEvent(String mac, int repeat) {
420         updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_REPEAT, new DecimalType(repeat));
421     }
422
423     @Override
424     public void titleChangeEvent(String mac, String title) {
425         updateChannel(mac, CHANNEL_TITLE, new StringType(title));
426     }
427
428     @Override
429     public void albumChangeEvent(String mac, String album) {
430         updateChannel(mac, CHANNEL_ALBUM, new StringType(album));
431     }
432
433     @Override
434     public void artistChangeEvent(String mac, String artist) {
435         updateChannel(mac, CHANNEL_ARTIST, new StringType(artist));
436     }
437
438     @Override
439     public void albumArtistChangeEvent(String mac, String albumArtist) {
440         updateChannel(mac, CHANNEL_ALBUM_ARTIST, new StringType(albumArtist));
441     }
442
443     @Override
444     public void trackArtistChangeEvent(String mac, String trackArtist) {
445         updateChannel(mac, CHANNEL_TRACK_ARTIST, new StringType(trackArtist));
446     }
447
448     @Override
449     public void bandChangeEvent(String mac, String band) {
450         updateChannel(mac, CHANNEL_BAND, new StringType(band));
451     }
452
453     @Override
454     public void composerChangeEvent(String mac, String composer) {
455         updateChannel(mac, CHANNEL_COMPOSER, new StringType(composer));
456     }
457
458     @Override
459     public void conductorChangeEvent(String mac, String conductor) {
460         updateChannel(mac, CHANNEL_CONDUCTOR, new StringType(conductor));
461     }
462
463     @Override
464     public void coverArtChangeEvent(String mac, String coverArtUrl) {
465         updateChannel(mac, CHANNEL_COVERART_DATA, createImage(downloadImage(mac, coverArtUrl)));
466     }
467
468     /**
469      * Download and cache the image data from an URL.
470      *
471      * @param url The URL of the image to be downloaded.
472      * @return A RawType object containing the image, null if the content type could not be found or the content type is
473      *         not an image.
474      */
475     private RawType downloadImage(String mac, String url) {
476         // Only get the image if this is my PlayerHandler instance
477         if (isMe(mac)) {
478             if (url != null && !url.isEmpty()) {
479                 String sanitizedUrl = sanitizeUrl(url);
480                 RawType image = IMAGE_CACHE.putIfAbsentAndGet(url, () -> {
481                     logger.debug("Trying to download the content of URL {}", sanitizedUrl);
482                     try {
483                         return HttpUtil.downloadImage(url);
484                     } catch (IllegalArgumentException e) {
485                         logger.debug("IllegalArgumentException when downloading image from {}", sanitizedUrl, e);
486                         return null;
487                     }
488                 });
489                 if (image == null) {
490                     logger.debug("Failed to download the content of URL {}", sanitizedUrl);
491                     return null;
492                 } else {
493                     return image;
494                 }
495             }
496         }
497         return null;
498     }
499
500     /*
501      * Replaces the password in the URL, if present
502      */
503     private String sanitizeUrl(String url) {
504         String sanitizedUrl = url;
505         try {
506             URI uri = new URI(url);
507             String userInfo = uri.getUserInfo();
508             if (userInfo != null) {
509                 String[] userInfoParts = userInfo.split(":");
510                 if (userInfoParts.length == 2) {
511                     sanitizedUrl = url.replace(userInfoParts[1], "**********");
512                 }
513             }
514         } catch (URISyntaxException e) {
515             // Just return what was passed in
516         }
517         return sanitizedUrl;
518     }
519
520     /**
521      * Wrap the given RawType and return it as {@link State} or return {@link UnDefType#UNDEF} if the RawType is null.
522      */
523     private State createImage(RawType image) {
524         if (image == null) {
525             return UnDefType.UNDEF;
526         } else {
527             return image;
528         }
529     }
530
531     @Override
532     public void yearChangeEvent(String mac, String year) {
533         updateChannel(mac, CHANNEL_YEAR, new StringType(year));
534     }
535
536     @Override
537     public void genreChangeEvent(String mac, String genre) {
538         updateChannel(mac, CHANNEL_GENRE, new StringType(genre));
539     }
540
541     @Override
542     public void remoteTitleChangeEvent(String mac, String title) {
543         updateChannel(mac, CHANNEL_REMOTE_TITLE, new StringType(title));
544     }
545
546     @Override
547     public void irCodeChangeEvent(String mac, String ircode) {
548         if (isMe(mac)) {
549             postCommand(CHANNEL_IRCODE, new StringType(ircode));
550         }
551     }
552
553     @Override
554     public void buttonsChangeEvent(String mac, String likeCommand, String unlikeCommand) {
555         if (isMe(mac)) {
556             this.likeCommand = likeCommand;
557             this.unlikeCommand = unlikeCommand;
558             logger.trace("Player {} got a button change event: like='{}' unlike='{}'", mac, likeCommand, unlikeCommand);
559         }
560     }
561
562     @Override
563     public void connectedStateChangeEvent(String mac, boolean connected) {
564         if (isMe(mac)) {
565             this.connected = connected;
566             updateThingStatus();
567         }
568     }
569
570     @Override
571     public void updateFavoritesListEvent(List<Favorite> favorites) {
572         logger.trace("Player {} updating favorites list with {} favorites", mac, favorites.size());
573         List<StateOption> options = new ArrayList<>();
574         for (Favorite favorite : favorites) {
575             options.add(new StateOption(favorite.shortId, favorite.name));
576         }
577         stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_FAVORITES_PLAY), options);
578     }
579
580     /**
581      * Update a channel if the mac matches our own
582      *
583      * @param mac
584      * @param channelID
585      * @param state
586      */
587     private void updateChannel(String mac, String channelID, State state) {
588         if (isMe(mac)) {
589             State prevState = stateMap.put(channelID, state);
590             if (prevState == null || !prevState.equals(state)) {
591                 logger.trace("Updating channel {} for thing {} with mac {} to state {}", channelID, getThing().getUID(),
592                         mac, state);
593                 updateState(channelID, state);
594             }
595         }
596     }
597
598     /**
599      * Helper methods to get the current state of the player
600      *
601      * @return
602      */
603     int currentVolume() {
604         return cachedStateAsInt(CHANNEL_VOLUME);
605     }
606
607     int currentPlayingTime() {
608         return cachedStateAsInt(CHANNEL_CURRENT_PLAYING_TIME);
609     }
610
611     int currentNumberPlaylistTracks() {
612         return cachedStateAsInt(CHANNEL_NUMBER_PLAYLIST_TRACKS);
613     }
614
615     int currentPlaylistIndex() {
616         return cachedStateAsInt(CHANNEL_PLAYLIST_INDEX);
617     }
618
619     boolean currentPower() {
620         return cachedStateAsBoolean(CHANNEL_POWER, OnOffType.ON);
621     }
622
623     boolean currentStop() {
624         return cachedStateAsBoolean(CHANNEL_STOP, OnOffType.ON);
625     }
626
627     boolean currentControl() {
628         return cachedStateAsBoolean(CHANNEL_CONTROL, PlayPauseType.PLAY);
629     }
630
631     boolean currentMute() {
632         return cachedStateAsBoolean(CHANNEL_MUTE, OnOffType.ON);
633     }
634
635     int currentShuffle() {
636         return cachedStateAsInt(CHANNEL_CURRENT_PLAYLIST_SHUFFLE);
637     }
638
639     int currentRepeat() {
640         return cachedStateAsInt(CHANNEL_CURRENT_PLAYLIST_REPEAT);
641     }
642
643     private boolean cachedStateAsBoolean(String key, @NonNull State activeState) {
644         return activeState.equals(stateMap.get(key));
645     }
646
647     private int cachedStateAsInt(String key) {
648         State state = stateMap.get(key);
649         return state instanceof DecimalType decimalValue ? decimalValue.intValue() : 0;
650     }
651
652     /**
653      * Ticks away when in a play state to keep current track time
654      */
655     private void timeCounter() {
656         timeCounterJob = scheduler.scheduleWithFixedDelay(() -> {
657             if (playing) {
658                 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new DecimalType(currentTime++));
659             }
660         }, 0, 1, TimeUnit.SECONDS);
661     }
662
663     private boolean isMe(String mac) {
664         return mac.equals(this.mac);
665     }
666
667     /**
668      * Returns our server handler if set
669      *
670      * @return
671      */
672     public SqueezeBoxServerHandler getSqueezeBoxServerHandler() {
673         return this.squeezeBoxServerHandler;
674     }
675
676     /**
677      * Returns the MAC address for this player
678      *
679      * @return
680      */
681     public String getMac() {
682         return this.mac;
683     }
684
685     /*
686      * Give the notification player access to the notification timeout
687      */
688     public int getNotificationTimeout() {
689         return getConfigAs(SqueezeBoxPlayerConfig.class).notificationTimeout;
690     }
691
692     /*
693      * Used by the AudioSink to get the volume level that should be used for the notification.
694      * Priority for determining volume is:
695      * - volume is provided in the say/playSound actions
696      * - volume is contained in the player thing's configuration
697      * - current player volume setting
698      */
699     public PercentType getNotificationSoundVolume() {
700         // Get the notification sound volume from this player thing's configuration
701         Integer configNotificationSoundVolume = getConfigAs(SqueezeBoxPlayerConfig.class).notificationVolume;
702
703         // Determine which volume to use
704         Integer currentNotificationSoundVolume;
705         if (notificationSoundVolume != null) {
706             currentNotificationSoundVolume = notificationSoundVolume;
707         } else if (configNotificationSoundVolume != null) {
708             currentNotificationSoundVolume = configNotificationSoundVolume;
709         } else {
710             currentNotificationSoundVolume = Integer.valueOf(currentVolume());
711         }
712         return new PercentType(currentNotificationSoundVolume.intValue());
713     }
714
715     /*
716      * Used by the AudioSink to set the volume level that should be used to play the notification
717      */
718     public void setNotificationSoundVolume(PercentType newNotificationSoundVolume) {
719         if (newNotificationSoundVolume != null) {
720             notificationSoundVolume = Integer.valueOf(newNotificationSoundVolume.intValue());
721         }
722     }
723
724     /*
725      * Play the notification.
726      */
727     public void playNotificationSoundURI(StringType uri) {
728         logger.debug("Play notification sound on player {} at URI {}", mac, uri);
729
730         try (SqueezeBoxNotificationPlayer notificationPlayer = new SqueezeBoxNotificationPlayer(this,
731                 squeezeBoxServerHandler, uri)) {
732             notificationPlayer.play();
733         } catch (InterruptedException e) {
734             logger.warn("Notification playback was interrupted", e);
735         } catch (SqueezeBoxTimeoutException e) {
736             logger.debug("SqueezeBoxTimeoutException during notification: {}", e.getMessage());
737         } finally {
738             notificationSoundVolume = null;
739         }
740     }
741
742     /*
743      * Return the IP and port of the OH2 web server
744      */
745     public String getHostAndPort() {
746         return callbackUrl;
747     }
748 }